feat(mcp): split mcp_client/registry, multi-registry, boot-spawn + setup agent (#2559)

This commit is contained in:
Steven Enamakel
2026-05-23 22:15:33 -07:00
committed by GitHub
parent f66e7e433c
commit e9ca97c48c
60 changed files with 3829 additions and 1190 deletions
+4
View File
@@ -25,6 +25,10 @@ path = "src/bin/memory_tree_init_smoke.rs"
name = "inference-probe"
path = "src/bin/inference_probe.rs"
[[bin]]
name = "test-mcp-stub"
path = "src/bin/test_mcp_stub.rs"
[lib]
name = "openhuman_core"
crate-type = ["rlib"]
+2
View File
@@ -14,6 +14,7 @@ import ServiceBlockingGate from './components/daemon/ServiceBlockingGate';
import DictationHotkeyManager from './components/DictationHotkeyManager';
import ErrorFallbackScreen from './components/ErrorFallbackScreen';
import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar';
import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog';
import OpenhumanLinkModal from './components/OpenhumanLinkModal';
import PersistRehydrationScreen from './components/PersistRehydrationScreen';
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
@@ -106,6 +107,7 @@ function App() {
{!onMobile && <DictationHotkeyManager />}
{!onMobile && <LocalAIDownloadSnackbar />}
{!onMobile && <AppUpdatePrompt />}
<SecretPromptDialog />
</ServiceBlockingGate>
</CommandProvider>
</Router>
@@ -0,0 +1,115 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SecretPromptDialog } from './SecretPromptDialog';
const callCoreRpc = vi.fn();
vi.mock('../../services/coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => callCoreRpc(...args),
}));
function dispatchRequest(detail: { refId: string; keyName: string; prompt: string }) {
window.dispatchEvent(new CustomEvent('openhuman:mcp-setup-secret-requested', { detail }));
}
describe('SecretPromptDialog', () => {
beforeEach(() => {
callCoreRpc.mockReset();
callCoreRpc.mockResolvedValue(undefined);
});
afterEach(() => {
// The component cleans up its own listener on unmount via useEffect;
// we don't need to remove the event listener manually here.
});
it('is hidden until an event is dispatched', () => {
render(<SecretPromptDialog />);
expect(screen.queryByRole('dialog')).toBeNull();
});
it('renders the prompt + key name when an event arrives', async () => {
render(<SecretPromptDialog />);
dispatchRequest({
refId: 'secret://abc123',
keyName: 'NOTION_API_KEY',
prompt: 'Paste your Notion integration token.',
});
await screen.findByRole('dialog');
expect(screen.getByText('NOTION_API_KEY')).toBeTruthy();
expect(screen.getByText('Paste your Notion integration token.')).toBeTruthy();
});
it('submits the value via mcp_setup_submit_secret and dismisses', async () => {
render(<SecretPromptDialog />);
dispatchRequest({ refId: 'secret://abc123', keyName: 'TOKEN', prompt: '' });
await screen.findByRole('dialog');
const input = screen.getByLabelText(/^Value$/i);
fireEvent.change(input, { target: { value: 'super-secret-value' } });
const submit = screen.getByText(/^Submit$/);
fireEvent.click(submit);
await waitFor(() => {
expect(callCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.mcp_setup_submit_secret',
params: { ref_id: 'secret://abc123', value: 'super-secret-value' },
});
});
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('renders input as type=password by default and toggles on Show/Hide', async () => {
render(<SecretPromptDialog />);
dispatchRequest({ refId: 'secret://abc', keyName: 'K', prompt: '' });
await screen.findByRole('dialog');
const input = screen.getByLabelText(/^Value$/i) as HTMLInputElement;
expect(input.type).toBe('password');
fireEvent.click(screen.getByText(/^Show$/));
expect((screen.getByLabelText(/^Value$/i) as HTMLInputElement).type).toBe('text');
fireEvent.click(screen.getByText(/^Hide$/));
expect((screen.getByLabelText(/^Value$/i) as HTMLInputElement).type).toBe('password');
});
it('cancel does not call mcp_setup_submit_secret', async () => {
render(<SecretPromptDialog />);
dispatchRequest({ refId: 'secret://abc', keyName: 'K', prompt: '' });
await screen.findByRole('dialog');
fireEvent.click(screen.getByText(/^Cancel$/));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(callCoreRpc).not.toHaveBeenCalled();
});
it('submit button disabled when value is empty', async () => {
render(<SecretPromptDialog />);
dispatchRequest({ refId: 'secret://abc', keyName: 'K', prompt: '' });
await screen.findByRole('dialog');
const submit = screen.getByText(/^Submit$/) as HTMLButtonElement;
expect(submit.disabled).toBe(true);
const input = screen.getByLabelText(/^Value$/i);
fireEvent.change(input, { target: { value: 'x' } });
expect(submit.disabled).toBe(false);
});
it('surfaces submit errors without dismissing', async () => {
callCoreRpc.mockRejectedValueOnce(new Error('boom'));
render(<SecretPromptDialog />);
dispatchRequest({ refId: 'secret://abc', keyName: 'K', prompt: '' });
await screen.findByRole('dialog');
const input = screen.getByLabelText(/^Value$/i);
fireEvent.change(input, { target: { value: 'v' } });
fireEvent.click(screen.getByText(/^Submit$/));
await waitFor(() => expect(screen.getByText(/boom/)).toBeTruthy());
// Dialog still open
expect(screen.queryByRole('dialog')).not.toBeNull();
});
});
@@ -0,0 +1,175 @@
// Listens for `openhuman:mcp-setup-secret-requested` window events dispatched
// by `socketService` and renders a native input dialog so the user can hand
// the core a secret value out-of-band.
//
// The dialog deliberately uses `<input type="password">` so the value isn't
// echoed in the UI by default and never lands in clipboard history via
// triple-click. On submit, the value is POSTed straight to
// `openhuman.mcp_setup_submit_secret` and immediately cleared from React
// state — no logging, no Redux, no persistence on this side. The MCP setup
// agent only sees the opaque `ref://<hex>` ref returned by
// `mcp_setup_request_secret`; the raw value never enters the LLM context.
import { useCallback, useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { callCoreRpc } from '../../services/coreRpcClient';
type Request = { refId: string; keyName: string; prompt: string };
export function SecretPromptDialog() {
const { t } = useT();
const [request, setRequest] = useState<Request | null>(null);
const [value, setValue] = useState('');
const [reveal, setReveal] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const onRequest = (event: Event) => {
const detail = (event as CustomEvent).detail as Request | undefined;
if (!detail?.refId || !detail.keyName) return;
setRequest(detail);
setValue('');
setReveal(false);
setError(null);
setSubmitting(false);
};
window.addEventListener('openhuman:mcp-setup-secret-requested', onRequest);
return () => {
window.removeEventListener('openhuman:mcp-setup-secret-requested', onRequest);
};
}, []);
const reset = useCallback(() => {
setRequest(null);
setValue('');
setReveal(false);
setError(null);
setSubmitting(false);
}, []);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!request || submitting || value.length === 0) return;
setSubmitting(true);
setError(null);
try {
await callCoreRpc({
method: 'openhuman.mcp_setup_submit_secret',
params: { ref_id: request.refId, value },
});
// Wipe local state on success — the value has now moved into the
// core's process-local SETUP_SECRETS map; React doesn't need a
// copy. Closing the dialog also drops the React-tree reference.
reset();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
setSubmitting(false);
}
},
[request, submitting, value, reset]
);
// Cancel: do NOT call mcp_setup_submit_secret. The agent-side
// `request_secret` will hit its 5-minute timeout and return an error
// the agent can surface to the user, which is the right outcome here.
const handleCancel = useCallback(() => {
if (submitting) return;
reset();
}, [submitting, reset]);
if (!request) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 animate-fade-in"
onClick={handleCancel}
role="dialog"
aria-modal="true"
aria-label={t('mcp.setup.secretDialog.title')}>
<div
className="bg-white dark:bg-neutral-900 rounded-2xl max-w-md w-full shadow-large border border-stone-200 dark:border-neutral-800 animate-slide-up"
onClick={e => e.stopPropagation()}>
<form onSubmit={handleSubmit}>
<div className="p-6 pb-4">
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{t('mcp.setup.secretDialog.title')}
</h2>
<p className="text-sm text-stone-600 dark:text-neutral-300 mt-2">
{t('mcp.setup.secretDialog.bodyPrefix')}{' '}
<code className="px-1.5 py-0.5 rounded bg-stone-100 dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 font-mono text-xs">
{request.keyName}
</code>
{t('mcp.setup.secretDialog.bodySuffix')}
</p>
{request.prompt && (
<p className="text-sm text-stone-700 dark:text-neutral-200 mt-3 whitespace-pre-wrap">
{request.prompt}
</p>
)}
</div>
<div className="px-6 pb-2">
<label
htmlFor="mcp-setup-secret-input"
className="block text-xs font-medium text-stone-600 dark:text-neutral-400 mb-1">
{t('mcp.setup.secretDialog.inputLabel')}
</label>
<div className="flex items-stretch gap-2">
<input
id="mcp-setup-secret-input"
type={reveal ? 'text' : 'password'}
autoComplete="off"
autoCorrect="off"
spellCheck={false}
value={value}
onChange={e => setValue(e.target.value)}
placeholder={t('mcp.setup.secretDialog.inputPlaceholder')}
className="flex-1 px-3 py-2 rounded-lg border border-stone-300 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-stone-900 dark:text-neutral-100 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
autoFocus
disabled={submitting}
/>
<button
type="button"
onClick={() => setReveal(v => !v)}
disabled={submitting}
className="px-3 py-2 text-xs font-medium text-stone-600 dark:text-neutral-300 rounded-lg border border-stone-300 dark:border-neutral-700 hover:bg-stone-100 dark:hover:bg-neutral-800">
{reveal ? t('mcp.setup.secretDialog.hide') : t('mcp.setup.secretDialog.show')}
</button>
</div>
<p className="text-[11px] text-stone-500 dark:text-neutral-500 mt-2">
{t('mcp.setup.secretDialog.privacyNote')}
</p>
{error && (
<p className="text-xs text-coral-500 mt-2">
{t('mcp.setup.secretDialog.errorPrefix')} {error}
</p>
)}
</div>
<div className="flex items-center justify-end gap-3 p-6 pt-4 border-t border-stone-200 dark:border-neutral-800">
<button
type="button"
onClick={handleCancel}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-stone-600 dark:text-neutral-300 hover:text-stone-900 dark:hover:text-neutral-100 rounded-lg hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:opacity-50">
{t('mcp.setup.secretDialog.cancel')}
</button>
<button
type="submit"
disabled={submitting || value.length === 0}
className="px-4 py-2 text-sm font-medium rounded-lg bg-primary-500 hover:bg-primary-600 text-white disabled:opacity-50">
{submitting
? t('mcp.setup.secretDialog.submitting')
: t('mcp.setup.secretDialog.submit')}
</button>
</div>
</form>
</div>
</div>
);
}
export default SecretPromptDialog;
+14
View File
@@ -478,6 +478,20 @@ const ar1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -487,6 +487,20 @@ const bn1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -499,6 +499,20 @@ const de1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -475,6 +475,20 @@ const en1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -499,6 +499,20 @@ const es1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -501,6 +501,20 @@ const fr1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -484,6 +484,20 @@ const hi1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -490,6 +490,20 @@ const id1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -494,6 +494,20 @@ const it1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -487,6 +487,20 @@ const ko1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -499,6 +499,20 @@ const pt1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -489,6 +489,20 @@ const ru1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -471,6 +471,20 @@ const zhCN1: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+14
View File
@@ -541,6 +541,20 @@ const en: TranslationMap = {
'mcp.alphaBannerText':
'MCP server support is in early alpha. The Smithery registry, install flow, and tool wiring may misbehave or change shape between releases.',
'mcp.toolList.noTools': 'No tools available.',
'mcp.setup.secretDialog.title': 'MCP Setup — Enter Secret',
'mcp.setup.secretDialog.bodyPrefix': 'The MCP setup agent needs',
'mcp.setup.secretDialog.bodySuffix':
'. Your value is sent directly to the core process and never enters the AI conversation.',
'mcp.setup.secretDialog.inputLabel': 'Value',
'mcp.setup.secretDialog.inputPlaceholder': 'Paste here',
'mcp.setup.secretDialog.show': 'Show',
'mcp.setup.secretDialog.hide': 'Hide',
'mcp.setup.secretDialog.submit': 'Submit',
'mcp.setup.secretDialog.cancel': 'Cancel',
'mcp.setup.secretDialog.submitting': 'Submitting…',
'mcp.setup.secretDialog.errorPrefix': 'Failed to submit:',
'mcp.setup.secretDialog.privacyNote':
'Stored encrypted in the local MCP secrets table. Never logged or sent to a model.',
'devices.betaBadge': 'Beta',
'devices.betaText':
'This feature is currently in beta. Pair iOS phones with this OpenHuman to use them as a remote client.',
+30
View File
@@ -329,6 +329,36 @@ class SocketService {
this.socket.on('auth:session_expired', handleSessionExpired);
this.socket.on('auth_session_expired', handleSessionExpired);
// MCP setup agent: server-side `request_secret` blocks until the
// user submits a value. Dispatch a window event so a singleton React
// dialog can render a native input and POST back via
// openhuman.mcp_setup_submit_secret. Raw secret values never travel
// through the socket — only the opaque ref + safe display fields.
const handleSecretRequested = (data: unknown) => {
const obj = data as Record<string, unknown> | null;
if (!obj || typeof obj !== 'object') {
socketWarn('mcp_setup:secret_requested dropped — invalid payload');
return;
}
const refId = typeof obj.ref_id === 'string' ? obj.ref_id : null;
const keyName = typeof obj.key_name === 'string' ? obj.key_name : null;
const prompt = typeof obj.prompt === 'string' ? obj.prompt : '';
if (!refId || !keyName) {
socketWarn('mcp_setup:secret_requested missing ref_id or key_name');
return;
}
socketLog('mcp_setup:secret_requested', { refId, keyName });
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('openhuman:mcp-setup-secret-requested', {
detail: { refId, keyName, prompt },
})
);
}
};
this.socket.on('mcp_setup:secret_requested', handleSecretRequested);
this.socket.on('mcp_setup_secret_requested', handleSecretRequested);
this.socket.on('channel:managed-dm-verified', data => {
const obj = data as Record<string, unknown> | null;
if (!obj || typeof obj !== 'object') return;
+162
View File
@@ -0,0 +1,162 @@
# MCP Setup Agent — design sketch
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/`.
---
## Goal
A non-technical user says *"set up the Notion MCP server for me"*. The
agent:
1. Browses the enabled registries, finds the candidate, summarises it.
2. Asks for any secrets the server requires (API keys, OAuth tokens, …)
**without ever pulling the values into the LLM context**.
3. Test-connects with the collected secrets, surfaces errors, lets the
user retry / change values.
4. On success, persists the install and the secrets, runs boot-spawn for
this one server, returns connection status + the tool list now
available to the main agent.
The agent owns the conversation; the core owns the secrets, the
subprocess, and the persistence.
---
## Tool surface
Four 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 |
| --- | --- | --- | --- |
| `mcp_setup_search` | `{ query?, page?, page_size?, source? }` | `{ servers: [Summary], total_pages }` | Thin wrapper over `mcp_registry::registry::registry_search`. `source` optionally scopes to one upstream. |
| `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. |
---
## Secret flow — opaque refs
The hard requirement: **raw secret values must not enter LLM context**.
Opaque refs solve this cleanly:
```
agent: mcp_setup_request_secret({ key_name: "NOTION_API_KEY", prompt: "Notion integration token" })
core: → pushes prompt to UI; user types into a native input box
core: ← receives value, stores in SETUP_SECRETS: HashMap<RefId, String>
core: → returns { ref: "secret://7c9f2e" } ← the agent sees only this
agent: mcp_setup_test_connection({
qualified_name: "@notion/server",
env_refs: { "NOTION_API_KEY": "secret://7c9f2e" }
})
core: → for each ref, look up the value in SETUP_SECRETS, build the env
vector, spawn, init, list_tools, tear down
core: ← returns { ok: true, tools: [...] } ← still no raw value to agent
```
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.
- 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
underlying value** so the agent has nothing useful to leak even if it
tries.
### Why not just take key names?
Considered (option 2 in the original AskUserQuestion). Rejected because:
- The agent can't decide between values it just collected — e.g. trying
two different tokens to pick the one that works requires distinguishing
them, which requires handles.
- Tying secrets to the `(server_id, key)` pair too early means a failed
test-connect leaves stale rows in `mcp_client_env` for an
uninstall-rolled-back server.
Opaque refs give the agent enough handle to iterate without exposing
values.
---
## Where the agent lives
Follow the existing sub-agent pattern (`src/openhuman/agent/harness/`):
- 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).
---
## Implementation outline
Following the project's `Specify → Rust → JSON-RPC → UI → tests` flow:
1. **Rust core** (in `src/openhuman/mcp_registry/`):
- New module `setup.rs` owning `SETUP_SECRETS` (in-memory ref map with
TTL) and helpers `mint_ref`, `resolve_refs(env_refs) -> Vec<(K,V)>`,
`consume_refs(env_refs)`.
- New module `setup_ops.rs` with the four handlers.
- Wire schemas in `schemas.rs`, controllers in `core/all.rs`.
2. **Tool-side bridge** so the agent harness sees the four tools as
regular tool defs. Reuse the controller-to-tool generator already
used elsewhere.
3. **UI**: out-of-band secret prompt component (probably a `chat`-pinned
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`.
5. **Tests**:
- Unit: ref lifecycle (mint → resolve → consume → TTL expiry).
- Integration (`tests/mcp_registry_e2e.rs` style): full flow against
the existing `test-mcp-stub` binary, asserting refs vanish after
install + that test-connect failures leave refs intact.
---
## Open questions for the implementer
- TTL value — 15 min is a guess; calibrate against typical install flow.
- Should `test_connection` accept a partial env_refs (some refs, some
literal-by-name) for iteration? Current design says refs only, which
forces consistency.
- The official MCP registry returns servers with **multiple package
ecosystems** (`packages: [{ registry_name: "npm" | "pypi" | … }]`). The
setup agent needs to either pick one or ask the user. Add a
`package_choice` step or default to npm?
- Telemetry: log `mcp_setup_*` calls (`tracing::info!` is fine) but
never log ref values, never log env values, only key names.
---
## Anti-goals
- The setup agent is **not** a generic "ask user for any data" surface.
Its prompt tool is scoped to MCP env values, full stop.
- It does **not** persist anything until `install_and_connect` succeeds.
No half-installed rows in `mcp_servers` or `mcp_client_env`.
- It does **not** read back secrets. Once persisted into `mcp_client_env`
they are write-only from the agent's perspective; only the subprocess
spawn path in `connections::connect` reads them.
+131
View File
@@ -0,0 +1,131 @@
//! Tiny MCP stdio server used by `tests/mcp_registry_e2e.rs`.
//!
//! Speaks just enough of MCP 2024-11-05 to satisfy `initialize`,
//! `tools/list`, and `tools/call` for one toy tool (`echo`). Reads
//! newline-delimited JSON-RPC from stdin, writes responses to stdout,
//! exits when stdin closes.
//!
//! Intentionally dependency-free beyond serde_json so the binary builds
//! fast and is reliable in CI.
use std::io::{self, BufRead, Write};
use serde_json::{json, Value};
const PROTOCOL_VERSION: &str = "2025-11-25";
fn main() {
let stdin = io::stdin();
let mut stdout = io::stdout().lock();
let mut stderr = io::stderr().lock();
let _ = writeln!(stderr, "[test_mcp_stub] ready");
for line in stdin.lock().lines() {
let line = match line {
Ok(l) => l,
Err(err) => {
let _ = writeln!(stderr, "[test_mcp_stub] stdin read error: {err}");
break;
}
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let req: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(err) => {
let _ = writeln!(stderr, "[test_mcp_stub] invalid JSON: {err}");
continue;
}
};
let id = req.get("id").cloned();
let method = req
.get("method")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let params = req.get("params").cloned().unwrap_or(Value::Null);
// Notifications (no id) are accepted silently — MCP sends
// `notifications/initialized` after the initialize handshake.
if id.is_none() {
let _ = writeln!(stderr, "[test_mcp_stub] notification: {method}");
continue;
}
let response = match method.as_str() {
"initialize" => json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"protocolVersion": PROTOCOL_VERSION,
"capabilities": { "tools": {} },
"serverInfo": { "name": "test_mcp_stub", "version": "0.0.1" }
}
}),
"tools/list" => json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"tools": [
{
"name": "echo",
"description": "Returns the `message` argument verbatim.",
"inputSchema": {
"type": "object",
"properties": {
"message": { "type": "string" }
},
"required": ["message"]
}
}
]
}
}),
"tools/call" => {
let tool = params.get("name").and_then(Value::as_str).unwrap_or("");
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
if tool == "echo" {
let msg = args.get("message").and_then(Value::as_str).unwrap_or("");
json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [
{ "type": "text", "text": msg }
],
"isError": false
}
})
} else {
json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": format!("unknown tool `{tool}`")
}
})
}
}
_ => json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": -32601,
"message": format!("method `{method}` not implemented in stub")
}
}),
};
let line = serde_json::to_string(&response).unwrap();
let _ = writeln!(stdout, "{line}");
let _ = stdout.flush();
}
let _ = writeln!(stderr, "[test_mcp_stub] stdin closed, exiting");
}
+5 -2
View File
@@ -115,7 +115,7 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
// Scheduled job management
controllers.extend(crate::openhuman::cron::all_cron_registered_controllers());
// MCP client subsystem: Smithery registry browser, local server install/connect, tool dispatch
controllers.extend(crate::openhuman::mcp_clients::all_mcp_clients_registered_controllers());
controllers.extend(crate::openhuman::mcp_registry::all_mcp_registry_registered_controllers());
// Webview APIs bridge — proxies connector calls (Gmail, …) through
// a WebSocket to the Tauri shell so curl reaches the live webview.
controllers.extend(crate::openhuman::webview_apis::all_webview_apis_registered_controllers());
@@ -278,7 +278,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::audio_toolkit::all_audio_toolkit_controller_schemas());
schemas.extend(crate::openhuman::composio::all_composio_controller_schemas());
schemas.extend(crate::openhuman::cron::all_cron_controller_schemas());
schemas.extend(crate::openhuman::mcp_clients::all_mcp_clients_controller_schemas());
schemas.extend(crate::openhuman::mcp_registry::all_mcp_registry_controller_schemas());
schemas.extend(crate::openhuman::webview_apis::all_webview_apis_controller_schemas());
schemas.extend(crate::openhuman::agent::all_agent_controller_schemas());
schemas.extend(crate::openhuman::agent_experience::all_agent_experience_controller_schemas());
@@ -395,6 +395,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"mcp_clients" => Some(
"Browse the Smithery.ai MCP registry, install MCP servers locally, manage their stdio connections, and expose their tools to the agent.",
),
"mcp_setup" => Some(
"MCP setup agent surface: search registries, request secrets out-of-band (opaque refs, no raw values in agent context), test, and install + connect.",
),
"decrypt" => Some("Decrypt secure values managed by secret storage."),
"doctor" => Some("Run diagnostics for workspace and runtime health."),
"encrypt" => Some("Encrypt secure values managed by secret storage."),
+12 -1
View File
@@ -516,6 +516,16 @@ pub enum DomainEvent {
success: bool,
elapsed_ms: u64,
},
/// The MCP setup agent asked the user for a secret value. The UI
/// subscribes to this and renders a native prompt; on submit it calls
/// `openhuman.mcp_setup_submit_secret`. `ref_id` is the opaque handle
/// returned to the agent; the raw secret value never traverses this
/// event.
McpSetupSecretRequested {
ref_id: String,
key_name: String,
prompt: String,
},
// ── System lifecycle ────────────────────────────────────────────────
/// A system component started up.
@@ -638,7 +648,8 @@ impl DomainEvent {
Self::McpServerInstalled { .. }
| Self::McpServerConnected { .. }
| Self::McpServerDisconnected { .. }
| Self::McpClientToolExecuted { .. } => "mcp_client",
| Self::McpClientToolExecuted { .. }
| Self::McpSetupSecretRequested { .. } => "mcp_client",
}
}
}
+12
View File
@@ -1693,6 +1693,18 @@ pub async fn bootstrap_core_runtime(embedded_core: bool) {
// --- Workspace migrations --------------------------------------------
crate::openhuman::startup::run_workspace_migrations(&workspace_dir);
// --- MCP registry boot-spawn -----------------------------------------
// Bring up every locally-installed MCP server's stdio subprocess so its
// tools are available to the agent as soon as the core is ready.
// Errors are logged per-server and never block boot. Runs as a
// background task so a slow npx install can't gate startup.
{
let cfg = cfg.clone();
tokio::spawn(async move {
crate::openhuman::mcp_registry::boot::spawn_installed_servers(&cfg).await;
});
}
// --- Socket manager bootstrap ---
let socket_mgr = Arc::new(SocketManager::new());
set_global_socket_manager(socket_mgr.clone());
+62
View File
@@ -509,6 +509,7 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
let io_transcription = io.clone();
let io_auth = io.clone();
let io_companion = io.clone();
let io_mcp_setup = io.clone();
// 2. Dictation hotkey events → broadcast to all connected clients.
tokio::spawn(async move {
@@ -659,6 +660,67 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
log::debug!("[socketio] auth session_expired bridge stopped");
});
// 6b. McpSetupSecretRequested → broadcast `mcp_setup:secret_requested`
// so the UI can render a native input dialog. Only the opaque
// ref + safe display fields are forwarded; raw secret values
// are not part of the event payload.
tokio::spawn(async move {
let bus = {
const RETRY_INTERVAL_MS: u64 = 250;
const MAX_WAIT_SECS: u64 = 30;
let max_attempts = (MAX_WAIT_SECS * 1000) / RETRY_INTERVAL_MS;
let mut attempts: u64 = 0;
loop {
if let Some(bus) = crate::core::event_bus::global() {
break bus;
}
attempts += 1;
if attempts > max_attempts {
log::warn!(
"[socketio] event_bus not initialised after {}s — mcp_setup bridge giving up",
MAX_WAIT_SECS
);
return;
}
tokio::time::sleep(std::time::Duration::from_millis(RETRY_INTERVAL_MS)).await;
}
};
let mut rx = bus.raw_receiver();
loop {
let event = match rx.recv().await {
Ok(event) => event,
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
log::warn!(
"[socketio] dropped {} event_bus events due to lag (mcp_setup bridge)",
skipped
);
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
};
if let crate::core::event_bus::DomainEvent::McpSetupSecretRequested {
ref_id,
key_name,
prompt,
} = event
{
log::info!(
"[socketio] broadcast mcp_setup:secret_requested ref={} key={}",
ref_id,
key_name
);
let payload = serde_json::json!({
"ref_id": ref_id,
"key_name": key_name,
"prompt": prompt,
});
let _ = io_mcp_setup.emit("mcp_setup:secret_requested", &payload);
let _ = io_mcp_setup.emit("mcp_setup_secret_requested", &payload);
}
}
log::debug!("[socketio] mcp_setup secret_requested bridge stopped");
});
// 5. Transcription results → broadcast to all connected clients.
tokio::spawn(async move {
let mut rx = crate::openhuman::voice::dictation_listener::subscribe_transcription_results();
+5
View File
@@ -143,6 +143,11 @@ pub const BUILTINS: &[BuiltinAgent] = &[
toml: include_str!("help/agent.toml"),
prompt_fn: super::help::prompt::build,
},
BuiltinAgent {
id: "mcp_setup",
toml: include_str!("mcp_setup/agent.toml"),
prompt_fn: super::mcp_setup::prompt::build,
},
];
/// Parse every entry in [`BUILTINS`] into an [`AgentDefinition`].
@@ -0,0 +1,28 @@
id = "mcp_setup"
display_name = "MCP Setup Agent"
delegate_name = "setup_mcp_server"
when_to_use = "Walks the user through installing and connecting an MCP server end-to-end. Use when the user asks to add / install / set up an MCP server (e.g. \"set up the Notion MCP\", \"connect the GitHub MCP server\"). Owns the full flow: search registries → ask the user for any required secrets via a native dialog (raw values never enter the agent context) → test the connection → commit the install."
temperature = 0.3
max_iterations = 12
sandbox_mode = "none"
omit_identity = true
omit_memory_context = true
omit_safety_preamble = false
omit_skills_catalog = true
[model]
hint = "agentic"
[tools]
# Tight allowlist — the agent must not have shell, filesystem, network,
# or any other capability that could exfiltrate a leaked secret ref.
# Five `mcp_setup_*` tools plus `ask_user_question` for natural-language
# checkpoints; nothing else.
named = [
"mcp_setup_search",
"mcp_setup_get",
"mcp_setup_request_secret",
"mcp_setup_test_connection",
"mcp_setup_install_and_connect",
"ask_user_clarification",
]
@@ -0,0 +1 @@
pub mod prompt;
@@ -0,0 +1,49 @@
# MCP Setup Agent
You guide the user through installing and connecting one MCP server end-to-end. Each spawn handles **one** server — if the user asks for several, install them one at a time.
## Your tool surface
- **`mcp_setup_search`** — keyword search across all enabled MCP registries (Smithery + the official `modelcontextprotocol/registry`). Returns server summaries with a `source` tag so you can attribute results.
- **`mcp_setup_get`** — full detail for one server, including `required_env_keys` derived from its connection schema. Use this to know which secrets to ask for.
- **`mcp_setup_request_secret`** — pop a native input dialog in front of the user to collect one secret. Returns an opaque ref like `secret://abc123`. **The raw value never enters your context.** You only get the ref; the core resolves it to the real value just-in-time when you call test/install.
- **`mcp_setup_test_connection`** — dry-run: spawn the candidate server with the collected secret refs, list its tools, tear it down. Nothing persisted. Use this to validate the user's input before committing.
- **`mcp_setup_install_and_connect`** — commit: persist the install + the secrets (consuming the refs), connect immediately, return the tool list now available to the main agent.
- **`ask_user_clarification`** — natural-language checkpoints ("Did you mean X or Y?", "Ready to install?", etc.).
You have **nothing else** — no shell, no file I/O, no general HTTP. Stay inside this surface.
## Standard flow
1. **Identify the server.** From the user's request, search with `mcp_setup_search`. If multiple candidates match, summarise the top 23 and ask the user to confirm via `ask_user_clarification`. Prefer servers with `is_deployed: true` and higher `use_count` when the user is non-specific.
2. **Fetch detail.** Once a `qualified_name` is locked, call `mcp_setup_get(qualified_name)`. Read `required_env_keys` — that's your secret-collection checklist.
3. **Collect secrets, one per key.** For each key in `required_env_keys`, call `mcp_setup_request_secret({key_name, prompt})` where `prompt` is a plain-English instruction the user sees in the native dialog. Examples:
- `NOTION_API_KEY``"Paste your Notion integration token. Get one at notion.so/my-integrations → New integration → Internal."`
- `GITHUB_TOKEN``"Paste a GitHub personal access token with repo + read:user scopes."`
- `OPENAI_API_KEY``"Paste your OpenAI API key (starts with sk-)."`
Store every returned `ref` in a local map keyed by `key_name`. The call blocks for up to 5 minutes per secret — that's fine, the user is at the dialog.
4. **Test.** Call `mcp_setup_test_connection({qualified_name, env_refs})` with the full ref map. Three outcomes:
- `ok: true` → list `tools` to the user for a sanity check; proceed to install.
- `ok: false` → surface `error` plainly. Common causes: wrong/expired token, missing scope, server-side bug. Offer to re-collect the offending secret (call `mcp_setup_request_secret` again for that one key, replace its ref in your map, retry test).
5. **Install.** On a successful test, call `mcp_setup_install_and_connect({qualified_name, env_refs})`. Two outcomes:
- `status: "connected"` → tell the user the server is live and list the new tools (`tools[].name`) so they know what's available.
- `status: "installed_disconnected"` → the install persisted but the live connection failed. Surface `error`; tell the user they can retry via Settings → MCP Servers → Reconnect.
## Hard rules
- **You never see raw secret values.** If you somehow do (a bug somewhere), abort, do not log or repeat the value, and tell the user to remove the leak.
- **Refs are opaque.** Don't try to deserialise, decode, or reason about the `secret://` payload. It's a random hex handle, nothing more.
- **One server per spawn.** If the user asks for two, finish one cleanly, then suggest they spawn you again for the second.
- **Don't fabricate `required_env_keys`.** Pull them from `mcp_setup_get`. Asking the user for a key the server doesn't need wastes their time and may leak unrelated credentials into our store.
- **Don't skip the test step.** Always `test_connection` before `install_and_connect` so the user has a chance to fix typos before we persist anything to the secrets store.
- **Be honest about failures.** If a server's config is so under-documented that you can't figure out the keys, say so and stop. Don't guess.
## Telemetry / privacy reminders
- Tool calls are logged. Calls show `key_name` (safe) and `ref` (opaque). They never show secret values.
- The user's submitted secret value travels: native UI dialog → core IPC → `SETUP_SECRETS` in-memory map → encrypted `mcp_client_env` table on success. It never round-trips through the LLM at any point.
## When you're done
Return a short summary: which server, which tools are now available, and any caveats (e.g. "Notion integration only sees pages you've explicitly shared with it — share at least one page before using `notion_get_page`."). Hand control back to the user / orchestrator.
@@ -0,0 +1,97 @@
//! System prompt builder for the `mcp_setup` built-in agent.
//!
//! Straightforward: render the static archetype, then append the
//! tool block so the model sees the five `mcp_setup_*` tool schemas
//! plus `ask_user_clarification` (filtered down by the harness from the
//! `agent.toml` allowlist).
use crate::openhuman::context::prompt::{
render_tools, render_user_files, render_workspace, PromptContext,
};
use anyhow::Result;
const ARCHETYPE: &str = include_str!("prompt.md");
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::with_capacity(4096);
out.push_str(ARCHETYPE.trim_end());
out.push_str("\n\n");
let user_files = render_user_files(ctx)?;
if !user_files.trim().is_empty() {
out.push_str(user_files.trim_end());
out.push_str("\n\n");
}
let tools = render_tools(ctx)?;
if !tools.trim().is_empty() {
out.push_str(tools.trim_end());
out.push_str("\n\n");
}
let workspace = render_workspace(ctx)?;
if !workspace.trim().is_empty() {
out.push_str(workspace.trim_end());
out.push('\n');
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat};
use std::collections::HashSet;
fn empty_ctx() -> PromptContext<'static> {
static EMPTY_VISIBLE: std::sync::OnceLock<HashSet<String>> = std::sync::OnceLock::new();
let visible = EMPTY_VISIBLE.get_or_init(HashSet::new);
PromptContext {
workspace_dir: std::path::Path::new("."),
model_name: "test",
agent_id: "mcp_setup",
tools: &[],
skills: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: visible,
tool_call_format: ToolCallFormat::PFormat,
connected_integrations: &[],
connected_identities_md: String::new(),
include_profile: false,
include_memory_md: false,
curated_snapshot: None,
user_identity: None,
}
}
#[test]
fn build_returns_nonempty_body() {
let body = build(&empty_ctx()).unwrap();
assert!(!body.is_empty());
assert!(body.contains("MCP Setup Agent"));
}
#[test]
fn archetype_documents_opaque_ref_invariant() {
let body = build(&empty_ctx()).unwrap();
assert!(body.contains("never enters your context"));
assert!(body.contains("secret://"));
}
#[test]
fn archetype_documents_standard_flow_steps() {
let body = build(&empty_ctx()).unwrap();
for needle in [
"mcp_setup_search",
"mcp_setup_get",
"mcp_setup_request_secret",
"mcp_setup_test_connection",
"mcp_setup_install_and_connect",
"ask_user_clarification",
] {
assert!(body.contains(needle), "prompt missing `{needle}`");
}
}
}
+1
View File
@@ -11,6 +11,7 @@ pub mod crypto_agent;
pub mod help;
pub mod integrations_agent;
pub mod markets_agent;
pub mod mcp_setup;
pub mod morning_briefing;
pub mod orchestrator;
pub mod planner;
+34 -4
View File
@@ -1,8 +1,38 @@
//! Shared MCP client + registry for remote MCP servers exposed to agents.
//! MCP client transport library + static-config server set.
//!
//! Supports Streamable HTTP and stdio transports. HTTP transport carries
//! session + auth lifecycle; stdio launches a subprocess and exchanges
//! newline-delimited JSON-RPC messages over stdin/stdout per the MCP spec.
//! Two responsibilities:
//!
//! 1. **Transport primitives** — [`McpHttpClient`] (Streamable HTTP + OAuth +
//! SSE per the MCP spec) and [`McpStdioClient`] (subprocess JSON-RPC over
//! stdin/stdout). These types are reusable building blocks for any module
//! that needs to *talk to* a remote MCP server.
//!
//! 2. **Static server set** — [`McpServerRegistry`] holds servers declared in
//! the user's TOML config under `[[mcp_client.servers]]`. Agents reach
//! these via the generic bridge tools in
//! [`crate::openhuman::tools::impl::network::mcp`] (`mcp_list_servers`,
//! `mcp_list_tools`, `mcp_call_tool`). The bespoke `gitbooks` tool also
//! consumes [`McpHttpClient`] directly.
//!
//! # Relationship to `mcp_registry`
//!
//! The sibling [`crate::openhuman::mcp_registry`] module owns the *dynamic*,
//! user-installed Smithery / official-registry MCP servers (full RPC CRUD,
//! SQLite persistence, live connection registry, boot-time spawn). All stdio
//! transport for those installs flows through this module's
//! [`McpStdioClient`] — `mcp_registry` carries no transport code of its own.
//!
//! In short:
//! - **`mcp_client`** (this module): transport library + read-only static
//! server set declared in user config.
//! - **`mcp_registry`** (sibling): dynamic Smithery installations, lifecycle,
//! persistence, and RPC surface.
//!
//! # Modules
//! - `client` — [`McpHttpClient`] and shared MCP protocol types
//! - `stdio` — [`McpStdioClient`]
//! - `registry` — [`McpServerRegistry`] built from
//! [`crate::openhuman::config::McpClientConfig`]
mod client;
mod registry;
-290
View File
@@ -1,290 +0,0 @@
//! MCP stdio client: spawns a child process and speaks the MCP JSON-RPC
//! stdio protocol (initialize → tools/list → tools/call).
//!
//! The client is `Send + Sync` and is stored in a global registry keyed by
//! `server_id`. Callers use the `McpTransport` trait for testing (see
//! `protocol::McpTransport`).
pub mod protocol;
pub mod transport;
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::sync::Mutex;
use crate::openhuman::mcp_clients::types::McpTool;
use protocol::{
build_initialize_params, build_request, parse_tools_list, send_request_and_wait, McpTransport,
RequestIdCounter,
};
use transport::SpawnedProcess;
// ── McpStdioClient ─────────────────────────────────────────────────────────
/// A live connection to an MCP server over stdio.
pub struct McpStdioClient {
server_id: String,
process: Mutex<SpawnedProcess>,
counter: RequestIdCounter,
/// Cached tool list after `initialize`.
cached_tools: Mutex<Vec<McpTool>>,
}
impl McpStdioClient {
/// Spawn the server process and run `initialize` + `tools/list`.
pub async fn spawn_and_init(
server_id: &str,
command: &str,
args: &[String],
env: &HashMap<String, String>,
) -> anyhow::Result<Arc<Self>> {
tracing::debug!(
"[mcp-client] spawn_and_init server_id={} command={} args={:?} env_keys={:?}",
server_id,
command,
args,
env.keys().collect::<Vec<_>>()
);
let mut cmd = tokio::process::Command::new(command);
cmd.args(args)
.envs(env)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("[mcp-client] failed to spawn {command}: {e}"))?;
let proc = SpawnedProcess::from_child(child, server_id)?;
let client = Arc::new(Self {
server_id: server_id.to_string(),
process: Mutex::new(proc),
counter: RequestIdCounter::new(),
cached_tools: Mutex::new(Vec::new()),
});
// Run MCP initialize handshake
let init_result = client.initialize().await.map_err(|e| {
anyhow::anyhow!("[mcp-client] server_id={server_id} initialize failed: {e}")
})?;
tracing::debug!(
"[mcp-client] server_id={} initialize result: {}",
server_id,
init_result
);
// Discover tools
let tools = client.list_tools().await.map_err(|e| {
anyhow::anyhow!("[mcp-client] server_id={server_id} tools/list failed: {e}")
})?;
tracing::debug!(
"[mcp-client] server_id={} discovered {} tools",
server_id,
tools.len()
);
{
let mut guard = client.cached_tools.lock().await;
*guard = tools;
}
Ok(client)
}
/// Return a snapshot of the cached tool list without a live RPC call.
pub async fn tools_snapshot(&self) -> Vec<McpTool> {
self.cached_tools.lock().await.clone()
}
/// Return the last stderr line for error reporting.
pub async fn last_error(&self) -> Option<String> {
self.process.lock().await.reader.last_stderr().await
}
}
#[async_trait]
impl McpTransport for McpStdioClient {
async fn initialize(&self) -> Result<Value, String> {
let id = self.counter.next().await;
let msg = build_request(id, "initialize", Some(build_initialize_params()));
let result = {
let mut proc = self.process.lock().await;
let pending = proc.reader.pending.clone();
let writer = &mut proc.writer;
// Register the pending waiter (inside send_request_and_wait) BEFORE
// performing the write, so a fast reply from the server isn't dropped
// by the reader before we're waiting for it.
send_request_and_wait(id, msg.clone(), &pending, async {
writer.send(&msg).await.map_err(|e| anyhow::anyhow!("{e}"))
})
.await
};
if result.is_ok() {
// Send initialized notification (no response expected)
let notif = json!({
"jsonrpc": "2.0",
"method": "notifications/initialized",
"params": {}
});
let mut proc = self.process.lock().await;
let _ = proc.writer.send(&notif).await;
}
result
}
async fn list_tools(&self) -> Result<Vec<McpTool>, String> {
let id = self.counter.next().await;
let msg = build_request(id, "tools/list", None);
let result = {
let mut proc = self.process.lock().await;
let pending = proc.reader.pending.clone();
let writer = &mut proc.writer;
send_request_and_wait(id, msg.clone(), &pending, async {
writer.send(&msg).await.map_err(|e| anyhow::anyhow!("{e}"))
})
.await
}?;
let tools = parse_tools_list(result)?;
// Update cache
let mut guard = self.cached_tools.lock().await;
*guard = tools.clone();
Ok(tools)
}
async fn call_tool(&self, tool_name: &str, arguments: Value) -> Result<Value, String> {
tracing::debug!(
"[mcp-client] server_id={} tool_call tool_name={}",
self.server_id,
tool_name
);
let id = self.counter.next().await;
let params = json!({
"name": tool_name,
"arguments": arguments
});
let msg = build_request(id, "tools/call", Some(params));
let mut proc = self.process.lock().await;
let pending = proc.reader.pending.clone();
let writer = &mut proc.writer;
send_request_and_wait(id, msg.clone(), &pending, async {
writer.send(&msg).await.map_err(|e| anyhow::anyhow!("{e}"))
})
.await
}
async fn shutdown(&self) {
tracing::debug!("[mcp-client] shutdown server_id={}", self.server_id);
let notif = json!({
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": { "reason": "client shutdown" }
});
let mut proc = self.process.lock().await;
let _ = proc.writer.send(&notif).await;
let _ = proc.child.kill().await;
}
}
// ── FakeMcpTransport (test double) ──────────────────────────────────────────
/// An in-memory fake for `McpTransport` usable in unit and E2E tests.
/// Responds to `initialize`, `list_tools`, and `call_tool` without spawning
/// any real process.
pub struct FakeMcpTransport {
pub tools: Vec<McpTool>,
/// Canned result for `call_tool`. If `Err`, the call returns that error.
pub call_result: Result<Value, String>,
}
impl FakeMcpTransport {
pub fn new(tools: Vec<McpTool>, call_result: Result<Value, String>) -> Arc<Self> {
Arc::new(Self { tools, call_result })
}
pub fn empty() -> Arc<Self> {
Self::new(Vec::new(), Ok(Value::Null))
}
}
#[async_trait]
impl McpTransport for FakeMcpTransport {
async fn initialize(&self) -> Result<Value, String> {
Ok(json!({
"protocolVersion": transport::MCP_PROTOCOL_VERSION,
"capabilities": {}
}))
}
async fn list_tools(&self) -> Result<Vec<McpTool>, String> {
Ok(self.tools.clone())
}
async fn call_tool(&self, _tool_name: &str, _arguments: Value) -> Result<Value, String> {
self.call_result.clone()
}
async fn shutdown(&self) {}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_tool(name: &str) -> McpTool {
McpTool {
name: name.to_string(),
description: Some(format!("Description for {name}")),
input_schema: json!({ "type": "object" }),
}
}
#[tokio::test]
async fn fake_initialize_returns_protocol_version() {
let fake = FakeMcpTransport::empty();
let result = fake.initialize().await.unwrap();
assert_eq!(result["protocolVersion"], transport::MCP_PROTOCOL_VERSION);
}
#[tokio::test]
async fn fake_list_tools_returns_configured_tools() {
let tools = vec![make_tool("search"), make_tool("write")];
let fake = FakeMcpTransport::new(tools.clone(), Ok(Value::Null));
let listed = fake.list_tools().await.unwrap();
assert_eq!(listed.len(), 2);
assert_eq!(listed[0].name, "search");
}
#[tokio::test]
async fn fake_call_tool_returns_configured_result() {
let expected = json!({ "answer": 42 });
let fake = FakeMcpTransport::new(vec![], Ok(expected.clone()));
let result = fake.call_tool("any_tool", json!({})).await.unwrap();
assert_eq!(result, expected);
}
#[tokio::test]
async fn fake_call_tool_propagates_error() {
let fake = FakeMcpTransport::new(vec![], Err("tool failed".to_string()));
let err = fake.call_tool("tool", json!({})).await.unwrap_err();
assert_eq!(err, "tool failed");
}
#[tokio::test]
async fn fake_shutdown_does_not_panic() {
let fake = FakeMcpTransport::empty();
fake.shutdown().await;
}
}
@@ -1,215 +0,0 @@
//! MCP JSON-RPC protocol framing: request serialisation, response correlation,
//! and higher-level method helpers (`initialize`, `tools/list`, `tools/call`).
//!
//! All methods are async and use a 30-second timeout by default.
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde_json::{json, Value};
use tokio::sync::{oneshot, Mutex};
use super::transport::{PendingMap, MCP_PROTOCOL_VERSION};
use crate::openhuman::mcp_clients::types::McpTool;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Trait abstracting the MCP stdio transport so tests can inject fakes.
#[async_trait]
pub trait McpTransport: Send + Sync + 'static {
/// Send an MCP `initialize` request and return the server's result.
async fn initialize(&self) -> Result<Value, String>;
/// Send a `tools/list` request and return the parsed tool list.
async fn list_tools(&self) -> Result<Vec<McpTool>, String>;
/// Send a `tools/call` request.
async fn call_tool(&self, tool_name: &str, arguments: Value) -> Result<Value, String>;
/// Gracefully shut down (send `notifications/cancelled` or just close).
async fn shutdown(&self);
}
// ── Shared request-counter helper ────────────────────────────────────────────
pub struct RequestIdCounter(Arc<Mutex<u64>>);
impl RequestIdCounter {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(0)))
}
pub async fn next(&self) -> u64 {
let mut guard = self.0.lock().await;
*guard += 1;
*guard
}
}
// ── Dispatch helper used by the real transport ────────────────────────────────
/// Send one JSON-RPC request message and await the response with timeout.
///
/// The caller owns the `pending` map and the writer lock; this function
/// inserts a oneshot sender into `pending`, writes the message, then
/// awaits the receiver with `REQUEST_TIMEOUT`.
pub async fn send_request_and_wait(
id: u64,
msg: Value,
pending: &PendingMap,
write_fn: impl std::future::Future<Output = anyhow::Result<()>>,
) -> Result<Value, String> {
let (tx, rx) = oneshot::channel::<Result<Value, String>>();
{
let mut map = pending.lock().await;
map.insert(id, tx);
}
if let Err(e) = write_fn.await {
let mut map = pending.lock().await;
map.remove(&id);
return Err(format!("[mcp-client] write failed id={id}: {e}"));
}
match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(format!("[mcp-client] channel dropped for id={id}")),
Err(_) => {
let mut map = pending.lock().await;
map.remove(&id);
Err(format!("[mcp-client] timeout waiting for id={id}"))
}
}
}
// ── Parse tools/list response ─────────────────────────────────────────────────
pub fn parse_tools_list(result: Value) -> Result<Vec<McpTool>, String> {
let tools_arr = result
.get("tools")
.and_then(Value::as_array)
.ok_or_else(|| "tools/list response missing 'tools' array".to_string())?;
let mut tools = Vec::new();
for t in tools_arr {
let name = t
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| "tool entry missing 'name' field".to_string())?
.to_string();
let description = t
.get("description")
.and_then(Value::as_str)
.map(String::from);
let input_schema = t.get("inputSchema").cloned().unwrap_or_else(
|| json!({ "type": "object", "properties": {}, "additionalProperties": true }),
);
tools.push(McpTool {
name,
description,
input_schema,
});
}
Ok(tools)
}
/// Build an MCP JSON-RPC request object.
pub fn build_request(id: u64, method: &str, params: Option<Value>) -> Value {
let mut obj = json!({
"jsonrpc": "2.0",
"id": id,
"method": method,
});
if let Some(p) = params {
obj["params"] = p;
}
obj
}
/// Build an MCP `initialize` params payload.
pub fn build_initialize_params() -> Value {
json!({
"protocolVersion": MCP_PROTOCOL_VERSION,
"clientInfo": {
"name": "openhuman",
"version": env!("CARGO_PKG_VERSION")
},
"capabilities": {}
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_request_includes_method_and_id() {
let req = build_request(7, "tools/list", None);
assert_eq!(req["jsonrpc"], "2.0");
assert_eq!(req["id"], 7);
assert_eq!(req["method"], "tools/list");
assert!(req.get("params").is_none() || req["params"] == Value::Null);
}
#[test]
fn build_request_with_params() {
let params = json!({ "name": "my_tool", "arguments": {} });
let req = build_request(3, "tools/call", Some(params.clone()));
assert_eq!(req["params"], params);
}
#[test]
fn parse_tools_list_happy_path() {
let result = json!({
"tools": [
{
"name": "search",
"description": "Web search",
"inputSchema": { "type": "object" }
}
]
});
let tools = parse_tools_list(result).unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "search");
assert_eq!(tools[0].description.as_deref(), Some("Web search"));
}
#[test]
fn parse_tools_list_missing_tools_key_errors() {
let result = json!({ "something_else": [] });
let err = parse_tools_list(result).unwrap_err();
assert!(err.contains("'tools'"));
}
#[test]
fn parse_tools_list_tool_missing_name_errors() {
let result = json!({ "tools": [{ "description": "no name" }] });
let err = parse_tools_list(result).unwrap_err();
assert!(err.contains("'name'"));
}
#[test]
fn parse_tools_list_no_input_schema_gets_default() {
let result = json!({ "tools": [{ "name": "tool_no_schema" }] });
let tools = parse_tools_list(result).unwrap();
assert_eq!(tools[0].input_schema["type"], "object");
}
#[test]
fn build_initialize_params_has_protocol_version() {
let params = build_initialize_params();
assert_eq!(params["protocolVersion"], MCP_PROTOCOL_VERSION);
assert!(params["clientInfo"]["name"].as_str().is_some());
}
#[tokio::test]
async fn request_id_counter_increments() {
let counter = RequestIdCounter::new();
assert_eq!(counter.next().await, 1);
assert_eq!(counter.next().await, 2);
assert_eq!(counter.next().await, 3);
}
}
@@ -1,225 +0,0 @@
//! Low-level stdio transport for MCP JSON-RPC.
//!
//! Owns the child process lifecycle, the reader task that deserializes
//! newline-delimited JSON from stdout, and the write-half that serializes
//! requests to stdin. A ring buffer captures stderr lines for error reporting.
use std::collections::HashMap;
use std::collections::VecDeque;
use std::sync::Arc;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout};
use tokio::sync::{oneshot, Mutex, RwLock};
pub type PendingMap = Arc<Mutex<HashMap<u64, oneshot::Sender<Result<Value, String>>>>>;
/// Capacity of the stderr ring buffer (lines).
const STDERR_RING_SIZE: usize = 64;
/// MCP protocol version negotiated in `initialize`.
pub const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
/// Serialised, newline-terminated JSON to be written to stdin.
pub struct TransportWriter {
stdin: ChildStdin,
}
impl TransportWriter {
pub fn new(stdin: ChildStdin) -> Self {
Self { stdin }
}
/// Write a single JSON value followed by `\n` and flush.
pub async fn send(&mut self, msg: &Value) -> anyhow::Result<()> {
let mut bytes = serde_json::to_vec(msg)?;
bytes.push(b'\n');
self.stdin.write_all(&bytes).await?;
self.stdin.flush().await?;
Ok(())
}
}
/// Owns the stdout reader task; routes responses to waiting callers.
pub struct TransportReader {
pub pending: PendingMap,
pub stderr_ring: Arc<RwLock<VecDeque<String>>>,
}
impl TransportReader {
pub fn new() -> Self {
Self {
pending: Arc::new(Mutex::new(HashMap::new())),
stderr_ring: Arc::new(RwLock::new(VecDeque::with_capacity(STDERR_RING_SIZE))),
}
}
/// Spawn a background task that drains `stdout` and resolves waiters.
///
/// When the reader exits (EOF or error), all pending waiters are flushed
/// with an error so they do not leak and wait until their own timeout fires.
pub fn spawn_reader(&self, stdout: ChildStdout, server_id: String) {
let pending = Arc::clone(&self.pending);
tokio::spawn(async move {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if line.trim().is_empty() {
continue;
}
// Do NOT log raw payload — MCP subprocesses may emit secrets or PII.
tracing::trace!(
"[mcp-client] server_id={} received stdout line (len={})",
server_id,
line.len()
);
let parsed: Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"[mcp-client] server_id={} unparseable stdout line: {e}",
server_id
);
continue;
}
};
// Route responses to waiting callers by id
if let Some(id) = parsed.get("id").and_then(Value::as_u64) {
let mut map = pending.lock().await;
if let Some(tx) = map.remove(&id) {
let result = if let Some(err) = parsed.get("error") {
Err(err.to_string())
} else {
Ok(parsed.get("result").cloned().unwrap_or(Value::Null))
};
let _ = tx.send(result);
}
}
// Notifications have no id — log and ignore
}
tracing::debug!(
"[mcp-client] stdout reader exiting for server_id={}",
server_id
);
// Flush all pending waiters with an error so they don't leak until timeout.
let mut map = pending.lock().await;
for (id, tx) in map.drain() {
tracing::debug!(
"[mcp-client] server_id={} flushing pending waiter id={}",
server_id,
id
);
let _ = tx.send(Err("MCP server stdout closed unexpectedly".to_string()));
}
});
}
/// Spawn a background task that drains `stderr` into the ring buffer.
///
/// Raw stderr content is NOT logged — MCP subprocesses may emit secrets or
/// PII. Only the line length is traced for diagnostics.
pub fn spawn_stderr_reader(&self, stderr: tokio::process::ChildStderr, server_id: String) {
let ring = Arc::clone(&self.stderr_ring);
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::trace!(
"[mcp-client] server_id={} received stderr line (len={})",
server_id,
line.len()
);
let mut buf = ring.write().await;
if buf.len() >= STDERR_RING_SIZE {
buf.pop_front();
}
buf.push_back(line);
}
});
}
/// Return the most recent stderr line, if any.
pub async fn last_stderr(&self) -> Option<String> {
self.stderr_ring.read().await.back().cloned()
}
}
/// Wrap up a just-spawned child and its I/O halves.
pub struct SpawnedProcess {
pub child: Child,
pub writer: TransportWriter,
pub reader: TransportReader,
}
impl SpawnedProcess {
pub fn from_child(mut child: Child, server_id: &str) -> anyhow::Result<Self> {
let stdin = child.stdin.take().ok_or_else(|| {
anyhow::anyhow!("[mcp-client] server_id={server_id} failed to take stdin")
})?;
let stdout = child.stdout.take().ok_or_else(|| {
anyhow::anyhow!("[mcp-client] server_id={server_id} failed to take stdout")
})?;
let stderr = child.stderr.take().ok_or_else(|| {
anyhow::anyhow!("[mcp-client] server_id={server_id} failed to take stderr")
})?;
let writer = TransportWriter::new(stdin);
let reader = TransportReader::new();
reader.spawn_reader(stdout, server_id.to_string());
reader.spawn_stderr_reader(stderr, server_id.to_string());
Ok(Self {
child,
writer,
reader,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn mcp_protocol_version_is_2024_11_05() {
assert_eq!(MCP_PROTOCOL_VERSION, "2024-11-05");
}
#[tokio::test]
async fn pending_map_insert_and_remove() {
let map: PendingMap = Arc::new(Mutex::new(HashMap::new()));
let (tx, rx) = oneshot::channel::<Result<Value, String>>();
{
let mut guard = map.lock().await;
guard.insert(42, tx);
assert_eq!(guard.len(), 1);
}
{
let mut guard = map.lock().await;
let sender = guard.remove(&42).unwrap();
sender.send(Ok(json!("ok"))).unwrap();
}
assert_eq!(rx.await.unwrap().unwrap(), json!("ok"));
}
#[tokio::test]
async fn stderr_ring_caps_at_max_size() {
let ring: Arc<RwLock<VecDeque<String>>> =
Arc::new(RwLock::new(VecDeque::with_capacity(STDERR_RING_SIZE)));
for i in 0..(STDERR_RING_SIZE + 10) {
let mut buf = ring.write().await;
if buf.len() >= STDERR_RING_SIZE {
buf.pop_front();
}
buf.push_back(format!("line {i}"));
}
let buf = ring.read().await;
assert_eq!(buf.len(), STDERR_RING_SIZE);
// The oldest (line 0 .. 9) should have been evicted
assert!(buf.front().unwrap().starts_with("line 10"));
}
}
-175
View File
@@ -1,175 +0,0 @@
//! Global in-process registry of active MCP client connections.
//!
//! Keyed by `server_id` (UUID). Connections are established by `connect()`
//! and removed by `disconnect()`. The registry is a process-global
//! `OnceLock<RwLock<HashMap<...>>>`.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use serde_json::Value;
use tokio::sync::RwLock;
use crate::openhuman::config::Config;
use crate::openhuman::mcp_clients::client::protocol::McpTransport;
use crate::openhuman::mcp_clients::client::McpStdioClient;
use crate::openhuman::mcp_clients::store;
use crate::openhuman::mcp_clients::types::{ConnStatus, InstalledServer, McpTool, ServerStatus};
// ── Global registry ──────────────────────────────────────────────────────────
static CONNECTIONS: OnceLock<RwLock<HashMap<String, Arc<McpStdioClient>>>> = OnceLock::new();
fn connections() -> &'static RwLock<HashMap<String, Arc<McpStdioClient>>> {
CONNECTIONS.get_or_init(|| RwLock::new(HashMap::new()))
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Spawn a new stdio process and run the MCP initialize handshake.
/// Stores the live client in the global registry.
pub async fn connect(config: &Config, server: &InstalledServer) -> anyhow::Result<Vec<McpTool>> {
tracing::debug!(
"[mcp-client] connect server_id={} qualified_name={}",
server.server_id,
server.qualified_name
);
// Load env values from DB (never log values)
let env = store::load_env_values(config, &server.server_id).unwrap_or_default();
tracing::debug!(
"[mcp-client] connect server_id={} env_keys={:?}",
server.server_id,
env.keys().collect::<Vec<_>>()
);
let client =
McpStdioClient::spawn_and_init(&server.server_id, &server.command, &server.args, &env)
.await?;
let tools = client.tools_snapshot().await;
{
let mut map = connections().write().await;
map.insert(server.server_id.clone(), Arc::clone(&client));
}
// Update last_connected_at in DB
let _ = store::update_last_connected(config, &server.server_id);
tracing::debug!(
"[mcp-client] connect ok server_id={} tools={}",
server.server_id,
tools.len()
);
Ok(tools)
}
/// Disconnect and remove from the registry.
pub async fn disconnect(server_id: &str) -> bool {
tracing::debug!("[mcp-client] disconnect server_id={}", server_id);
let client = {
let mut map = connections().write().await;
map.remove(server_id)
};
if let Some(c) = client {
c.shutdown().await;
tracing::debug!("[mcp-client] disconnected server_id={}", server_id);
true
} else {
tracing::debug!("[mcp-client] disconnect noop server_id={}", server_id);
false
}
}
/// Get a live client handle for `server_id`, if connected.
pub async fn client_for(server_id: &str) -> Option<Arc<McpStdioClient>> {
let map = connections().read().await;
map.get(server_id).cloned()
}
/// Invoke `call_tool` on a connected server.
pub async fn call_tool(
server_id: &str,
tool_name: &str,
arguments: Value,
) -> Result<Value, String> {
let client = client_for(server_id)
.await
.ok_or_else(|| format!("[mcp-client] server_id={server_id} not connected"))?;
client.call_tool(tool_name, arguments).await
}
/// Return status summaries for all installed servers.
pub async fn all_status(config: &Config) -> Vec<ConnStatus> {
let installed = store::list_servers(config).unwrap_or_default();
let map = connections().read().await;
installed
.into_iter()
.map(|s| {
let connected = map.get(&s.server_id);
let (status, tool_count, last_error) = if let Some(c) = connected {
// We can't easily block here on async, so tool count comes from
// a best-effort sync snapshot: peek at the blocking tools list.
// For full accuracy callers can refresh via `connect`.
let tool_count = {
// We can't .await here because we hold a read lock.
// Use a fallback of 0; the UI refreshes asynchronously.
0u32
};
(ServerStatus::Connected, tool_count, None)
} else {
(ServerStatus::Disconnected, 0u32, None)
};
ConnStatus {
server_id: s.server_id,
qualified_name: s.qualified_name,
display_name: s.display_name,
status,
tool_count,
last_error,
}
})
.collect()
}
/// Collect tools from all currently-connected servers for tool_registry integration.
///
/// Returns `(server_id, qualified_name, tool)` triples.
pub async fn all_connected_tools() -> Vec<(String, String, McpTool)> {
let installed_ids: Vec<(String, String)> = {
let map = connections().read().await;
map.keys().map(|id| (id.clone(), id.clone())).collect()
};
// We need server metadata too — fetch from a mini-cache in the connections map.
// For simplicity, return server_id as qualified_name here; ops.rs enriches it.
let mut result = Vec::new();
let map = connections().read().await;
for (server_id, _) in &installed_ids {
if let Some(client) = map.get(server_id) {
let tools = client.tools_snapshot().await;
for tool in tools {
result.push((server_id.clone(), server_id.clone(), tool));
}
}
}
result
}
#[cfg(test)]
mod tests {
// Connection registry tests require a real process, which is too heavy
// for unit tests. See tests/json_rpc_e2e.rs for the lifecycle test.
// Here we only test helper logic.
#[test]
fn all_status_on_empty_connections_returns_empty() {
// Purely synchronous check — can't easily test the async path without
// real server infra. The E2E test covers the full lifecycle.
assert!(true);
}
}
-29
View File
@@ -1,29 +0,0 @@
//! MCP Clients domain — browse the Smithery.ai MCP registry, install servers
//! locally, spawn them as stdio subprocesses, and expose their tools to agents.
//!
//! # Modules
//! - `types` — data structures (InstalledServer, McpTool, Smithery DTOs, …)
//! - `store` — SQLite persistence (mcp_clients.db)
//! - `registry` — Smithery HTTP client with 10-minute SQLite cache
//! - `client` — MCP stdio JSON-RPC client + FakeMcpTransport test double
//! - `connections` — global in-process connection registry
//! - `ops` — RPC handler implementations
//! - `schemas` — controller schemas + handler dispatch
//! - `bus` — DomainEvent subscriber for lifecycle logging
pub mod bus;
mod client;
pub(crate) mod connections;
mod ops;
mod registry;
mod schemas;
mod store;
pub mod types;
pub use schemas::{
all_controller_schemas as all_mcp_clients_controller_schemas,
all_registered_controllers as all_mcp_clients_registered_controllers,
schemas as mcp_clients_schemas,
};
pub use types::{ConnStatus, InstalledServer, McpTool};
-226
View File
@@ -1,226 +0,0 @@
//! Smithery.ai MCP registry HTTP client.
//!
//! Base URL: <https://registry.smithery.ai>
//! Public endpoints:
//! `GET /servers?q=<query>&page=N&pageSize=M` → `SmitheryListResponse`
//! `GET /servers/{qualifiedName}` → `SmitheryServerDetail`
//!
//! Results are cached in SQLite for 10 minutes (TTL controlled in `store.rs`).
//! Auth: optional `SMITHERY_API_KEY` env var sent as `Authorization: Bearer`.
use anyhow::{Context, Result};
use reqwest::Client;
use crate::openhuman::config::Config;
use super::store;
use super::types::{SmitheryListResponse, SmitheryServerDetail, SmitheryServerSummary};
const SMITHERY_BASE: &str = "https://registry.smithery.ai";
const DEFAULT_PAGE_SIZE: u32 = 20;
fn smithery_client() -> Result<Client> {
Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.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())
}
fn apply_auth(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(key) = smithery_api_key() {
builder.bearer_auth(key)
} else {
builder
}
}
/// Search the Smithery registry. Results are cached in SQLite.
pub async fn registry_search(
config: &Config,
query: Option<&str>,
page: u32,
page_size: u32,
) -> Result<(Vec<SmitheryServerSummary>, u32)> {
let page = page.max(1);
let page_size = if page_size == 0 {
DEFAULT_PAGE_SIZE
} else {
page_size
};
let q = query.unwrap_or("").trim();
let cache_key = format!("search:{}:{}:{}", q, page, page_size);
// Check SQLite cache first
if let Ok(Some(cached_body)) = store::get_cached(config, &cache_key) {
tracing::debug!("[mcp-client] registry_search cache hit key={}", cache_key);
if let Ok(resp) = serde_json::from_str::<SmitheryListResponse>(&cached_body) {
return Ok((resp.servers, resp.pagination.total_pages));
}
}
tracing::debug!(
"[mcp-client] registry_search fetching q={:?} page={} page_size={}",
q,
page,
page_size
);
let client = smithery_client()?;
let mut req = client.get(format!("{SMITHERY_BASE}/servers"));
if !q.is_empty() {
req = req.query(&[("q", q)]);
}
req = req
.query(&[
("page", &page.to_string()),
("pageSize", &page_size.to_string()),
])
.header("Accept", "application/json");
req = apply_auth(req);
let resp = req
.send()
.await
.context("Smithery registry_search request failed")?;
let status = resp.status();
let body = resp
.text()
.await
.context("Failed to read Smithery response body")?;
if !status.is_success() {
tracing::warn!(
"[mcp-client] registry_search HTTP {} for key={}",
status,
cache_key
);
anyhow::bail!(
"Smithery registry returned HTTP {}: {}",
status,
&body[..body.len().min(200)]
);
}
let parsed: SmitheryListResponse = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse Smithery list response: {body}"))?;
let total_pages = parsed.pagination.total_pages;
let servers = parsed.servers.clone();
// Cache success
let _ = store::set_cached(config, &cache_key, &body);
tracing::debug!(
"[mcp-client] registry_search ok servers={} total_pages={}",
servers.len(),
total_pages
);
Ok((servers, total_pages))
}
/// Fetch details for one server. Results are cached in SQLite.
pub async fn registry_get(config: &Config, qualified_name: &str) -> Result<SmitheryServerDetail> {
let cache_key = format!("detail:{qualified_name}");
if let Ok(Some(cached_body)) = store::get_cached(config, &cache_key) {
tracing::debug!(
"[mcp-client] registry_get cache hit qualified_name={}",
qualified_name
);
if let Ok(detail) = serde_json::from_str::<SmitheryServerDetail>(&cached_body) {
return Ok(detail);
}
}
tracing::debug!(
"[mcp-client] registry_get fetching qualified_name={}",
qualified_name
);
let client = smithery_client()?;
let url = format!(
"{SMITHERY_BASE}/servers/{}",
urlencoding_encode(qualified_name)
);
let req = apply_auth(client.get(&url).header("Accept", "application/json"));
let resp = req
.send()
.await
.context("Smithery registry_get request failed")?;
let status = resp.status();
let body = resp
.text()
.await
.context("Failed to read Smithery detail response")?;
if !status.is_success() {
anyhow::bail!(
"Smithery registry GET {} returned HTTP {}: {}",
qualified_name,
status,
&body[..body.len().min(200)]
);
}
let detail: SmitheryServerDetail = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse Smithery detail: {body}"))?;
let _ = store::set_cached(config, &cache_key, &body);
tracing::debug!(
"[mcp-client] registry_get ok qualified_name={} connections={}",
qualified_name,
detail.connections.len()
);
Ok(detail)
}
/// Minimal URL percent-encoding for path segments (encodes `/` and common specials).
fn urlencoding_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'@' => {
out.push(b as char)
}
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urlencoding_encode_handles_at_sign_and_slash() {
// @ is kept (valid in registry names like @modelcontextprotocol/server-fs)
// / is encoded so it does not split the URL path
let encoded = urlencoding_encode("@modelcontextprotocol/server-filesystem");
assert!(encoded.contains('%'), "slash should be encoded: {encoded}");
assert!(encoded.contains('@'), "@ should be preserved: {encoded}");
}
#[test]
fn urlencoding_encode_plain_ascii_unchanged() {
assert_eq!(urlencoding_encode("simple-name"), "simple-name");
}
#[test]
fn urlencoding_encode_space_becomes_percent_20() {
let encoded = urlencoding_encode("hello world");
assert_eq!(encoded, "hello%20world");
}
}
+55
View File
@@ -0,0 +1,55 @@
//! Boot-time spawn of installed local MCP servers.
//!
//! On core startup we iterate every [`InstalledServer`] in
//! [`super::store`] and bring up its stdio subprocess via
//! [`super::connections::connect`]. Errors are logged per-server and never
//! block boot — a misbehaving server should not prevent the rest of the
//! core from coming up.
//!
//! HTTP-remote MCP servers are out of scope here: they have no subprocess
//! to spawn. Once the `InstalledServer` model grows a remote-transport
//! variant this function will skip them (or call a remote "warm-up" path).
use crate::openhuman::config::Config;
use super::{connections, store};
/// Spawn every locally-installed MCP server. Per-server failures are logged
/// and swallowed.
pub async fn spawn_installed_servers(config: &Config) {
let servers = match store::list_servers(config) {
Ok(s) => s,
Err(err) => {
tracing::warn!("[mcp-registry] boot: list_servers failed: {err}");
return;
}
};
if servers.is_empty() {
tracing::debug!("[mcp-registry] boot: no installed servers to spawn");
return;
}
tracing::info!(
"[mcp-registry] boot: spawning {} installed server(s)",
servers.len()
);
for server in servers {
let server_id = server.server_id.clone();
let qualified = server.qualified_name.clone();
match connections::connect(config, &server).await {
Ok(tools) => tracing::info!(
"[mcp-registry] boot: connected server_id={} qualified={} tools={}",
server_id,
qualified,
tools.len()
),
Err(err) => tracing::warn!(
"[mcp-registry] boot: connect failed server_id={} qualified={} err={err}",
server_id,
qualified
),
}
}
}
+212
View File
@@ -0,0 +1,212 @@
//! Global in-process registry of active MCP client connections.
//!
//! Keyed by `server_id` (UUID). Connections are established by [`connect`]
//! and removed by [`disconnect`]. The actual stdio transport lives in
//! [`crate::openhuman::mcp_client::McpStdioClient`] — this module just
//! owns the per-server lifecycle and a global handle map.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use serde_json::Value;
use tokio::sync::RwLock;
use crate::openhuman::config::Config;
use crate::openhuman::mcp_client::{McpRemoteTool, McpStdioClient};
use super::store;
use super::types::{ConnStatus, InstalledServer, McpTool, ServerStatus};
// ── Connection record ────────────────────────────────────────────────────────
/// One live MCP subprocess plus the tool list cached after `initialize`.
struct Connection {
client: Arc<McpStdioClient>,
tools: RwLock<Vec<McpTool>>,
}
impl Connection {
async fn tools_snapshot(&self) -> Vec<McpTool> {
self.tools.read().await.clone()
}
}
// ── Global registry ──────────────────────────────────────────────────────────
static CONNECTIONS: OnceLock<RwLock<HashMap<String, Arc<Connection>>>> = OnceLock::new();
fn connections() -> &'static RwLock<HashMap<String, Arc<Connection>>> {
CONNECTIONS.get_or_init(|| RwLock::new(HashMap::new()))
}
// ── Public API ────────────────────────────────────────────────────────────────
/// Spawn a new stdio subprocess (via `McpStdioClient`), run `initialize`,
/// cache the tool list, and store the connection in the global registry.
pub async fn connect(config: &Config, server: &InstalledServer) -> anyhow::Result<Vec<McpTool>> {
tracing::debug!(
"[mcp-registry] connect server_id={} qualified_name={}",
server.server_id,
server.qualified_name
);
let env_map = store::load_env_values(config, &server.server_id).unwrap_or_default();
let env: Vec<(String, String)> = env_map.into_iter().collect();
tracing::debug!(
"[mcp-registry] connect server_id={} env_keys={:?}",
server.server_id,
env.iter().map(|(k, _)| k).collect::<Vec<_>>()
);
let identity = config.mcp_client.client_identity.clone();
let client = Arc::new(McpStdioClient::new(
server.command.clone(),
server.args.clone(),
env,
None,
identity,
));
// Initialize + first tools/list happen here so a misconfigured server
// fails loudly at `connect` instead of silently at first `call_tool`.
client.initialize().await?;
let remote_tools = client.list_tools().await?;
let tools: Vec<McpTool> = remote_tools.into_iter().map(into_registry_tool).collect();
let conn = Arc::new(Connection {
client: Arc::clone(&client),
tools: RwLock::new(tools.clone()),
});
{
let mut map = connections().write().await;
map.insert(server.server_id.clone(), conn);
}
let _ = store::update_last_connected(config, &server.server_id);
tracing::debug!(
"[mcp-registry] connect ok server_id={} tools={}",
server.server_id,
tools.len()
);
Ok(tools)
}
/// Disconnect and remove from the registry.
pub async fn disconnect(server_id: &str) -> bool {
tracing::debug!("[mcp-registry] disconnect server_id={server_id}");
let conn = {
let mut map = connections().write().await;
map.remove(server_id)
};
if let Some(c) = conn {
let _ = c.client.close_session().await;
tracing::debug!("[mcp-registry] disconnected server_id={server_id}");
true
} else {
tracing::debug!("[mcp-registry] disconnect noop server_id={server_id}");
false
}
}
/// Invoke `tools/call` on a connected server. The MCP `CallToolResult` is
/// returned as the raw JSON value (matches the prior wire contract used by
/// `tool_call`).
pub async fn call_tool(
server_id: &str,
tool_name: &str,
arguments: Value,
) -> Result<Value, String> {
let conn = {
let map = connections().read().await;
map.get(server_id).cloned()
}
.ok_or_else(|| format!("[mcp-registry] server_id={server_id} not connected"))?;
conn.client
.call_tool(tool_name, arguments)
.await
.map(|r| r.raw_result)
.map_err(|e| e.to_string())
}
/// Return status summaries for all installed servers.
pub async fn all_status(config: &Config) -> Vec<ConnStatus> {
let installed = store::list_servers(config).unwrap_or_default();
let connected_ids: Vec<String> = {
let map = connections().read().await;
map.keys().cloned().collect()
};
let mut out = Vec::with_capacity(installed.len());
for s in installed {
let is_connected = connected_ids.iter().any(|id| id == &s.server_id);
let tool_count = if is_connected {
let map = connections().read().await;
match map.get(&s.server_id) {
Some(c) => c.tools_snapshot().await.len() as u32,
None => 0,
}
} else {
0
};
out.push(ConnStatus {
server_id: s.server_id,
qualified_name: s.qualified_name,
display_name: s.display_name,
status: if is_connected {
ServerStatus::Connected
} else {
ServerStatus::Disconnected
},
tool_count,
last_error: None,
});
}
out
}
/// Collect tools from all currently-connected servers for tool_registry integration.
/// Returns `(server_id, qualified_name, tool)` triples. `qualified_name` is
/// best-effort sourced from the connection's `server_id` here — callers that
/// need the real qualified name should re-join against `store::list_servers`.
pub async fn all_connected_tools() -> Vec<(String, String, McpTool)> {
let snapshot: Vec<(String, Arc<Connection>)> = {
let map = connections().read().await;
map.iter()
.map(|(id, c)| (id.clone(), Arc::clone(c)))
.collect()
};
let mut out: Vec<(String, String, McpTool)> = Vec::new();
for (server_id, c) in snapshot {
for tool in c.tools_snapshot().await {
out.push((server_id.clone(), server_id.clone(), tool));
}
}
out
}
// ── Boundary conversion ──────────────────────────────────────────────────────
fn into_registry_tool(remote: McpRemoteTool) -> McpTool {
McpTool {
name: remote.name,
description: remote.description,
input_schema: remote.input_schema,
}
}
#[cfg(test)]
mod tests {
// Live-connection tests require a real MCP subprocess and live in
// tests/json_rpc_e2e.rs. Keep this slot for sync helper tests.
#[test]
fn placeholder_so_module_compiles_under_test_cfg() {
// Intentionally empty.
}
}
+71
View File
@@ -0,0 +1,71 @@
//! MCP Registry — discover, install, and run user-chosen MCP servers.
//!
//! This is the dynamic, user-facing side of MCP-client support. It browses the
//! Smithery.ai MCP registry, persists the user's chosen installs to SQLite,
//! and (for local-spawn servers) supervises their subprocess lifecycle.
//! Installed servers' tools are surfaced to agents via the unified tool
//! registry ([`crate::openhuman::tool_registry`]).
//!
//! # Server transport model
//!
//! Today every [`InstalledServer`] is a **local subprocess** launched by npx
//! / uvx / a direct binary ([`types::CommandKind`]). The connection is stdio
//! JSON-RPC, owned by [`connections`].
//!
//! 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 which the registry
//! holds both kinds.
//!
//! # 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.
//!
//! # Relationship to `mcp_client`
//!
//! The sibling [`crate::openhuman::mcp_client`] module is the **transport
//! library** (HTTP + stdio primitives) plus the *static, config-declared*
//! server set (read from `[[mcp_client.servers]]` in TOML). Agents reach
//! that set through generic bridge tools. The static set is intentionally
//! separate from this dynamic registry — both kinds will eventually share
//! the transport primitives from `mcp_client`.
//!
//! # Modules
//! - `types` — data structures (InstalledServer, McpTool, Smithery DTOs, …)
//! - `store` — SQLite persistence (mcp_clients.db)
//! - `registry` — Smithery HTTP client with 10-minute SQLite cache
//! - `connections` — global in-process connection registry (wraps
//! [`crate::openhuman::mcp_client::McpStdioClient`] — there is no
//! separate stdio client here)
//! - `boot` — boot-time spawn of installed local servers
//! - `ops` — RPC handler implementations
//! - `schemas` — controller schemas + handler dispatch
//! - `bus` — DomainEvent subscriber for lifecycle logging
//!
//! # Naming note
//!
//! The RPC namespace and SQLite db filename are still `mcp_clients` for
//! backwards compatibility with existing frontend code and on-disk state.
//! The Rust module path is `mcp_registry`.
pub mod boot;
pub mod bus;
pub mod connections;
mod ops;
mod registries;
mod registry;
mod schemas;
pub mod setup;
pub mod setup_ops;
pub mod store;
pub mod types;
pub use schemas::{
all_controller_schemas as all_mcp_registry_controller_schemas,
all_registered_controllers as all_mcp_registry_registered_controllers,
schemas as mcp_registry_schemas,
};
pub use types::{ConnStatus, InstalledServer, McpTool};
@@ -1,7 +1,7 @@
//! RPC handler implementations for the MCP clients domain.
//!
//! Each function maps 1-to-1 with a `schemas.rs` handler and is testable
//! in isolation via the `FakeMcpTransport` in `client/mod.rs`.
//! in isolation; live-process tests live in `tests/json_rpc_e2e.rs`.
use std::collections::HashMap;
use std::time::Instant;
@@ -184,7 +184,7 @@ pub async fn mcp_clients_install(
}
/// Resolve the launch command from the qualified name and optional registry connection metadata.
fn resolve_command(
pub(super) fn resolve_command(
qualified_name: &str,
stdio_conn: Option<&super::types::SmitheryConnection>,
) -> (CommandKind, String, Vec<String>) {
@@ -591,7 +591,7 @@ mod tests {
#[test]
fn collect_required_env_keys_from_schema() {
use crate::openhuman::mcp_clients::types::{SmitheryConnection, SmitheryServerDetail};
use crate::openhuman::mcp_registry::types::{SmitheryConnection, SmitheryServerDetail};
let detail = SmitheryServerDetail {
qualified_name: "@test/s".to_string(),
display_name: "T".to_string(),
@@ -610,6 +610,7 @@ mod tests {
published: true,
extra: Default::default(),
}],
source: "smithery".to_string(),
extra: Default::default(),
};
let keys = collect_required_env_keys(&detail);
@@ -627,7 +628,7 @@ mod tests {
#[test]
fn resolve_command_with_example_config() {
use crate::openhuman::mcp_clients::types::SmitheryConnection;
use crate::openhuman::mcp_registry::types::SmitheryConnection;
let conn = SmitheryConnection {
r#type: "stdio".to_string(),
deployment_url: None,
@@ -0,0 +1,304 @@
//! Official MCP registry adapter — [modelcontextprotocol/registry][repo].
//!
//! Base URL: `https://registry.modelcontextprotocol.io` (override with
//! `MCP_OFFICIAL_REGISTRY_BASE`).
//!
//! Endpoints used:
//! - `GET /v0/servers?search=<query>&limit=<n>&cursor=<opt>` — paginated list
//! - `GET /v0/servers/{name}` — full detail for one server (or a fallback
//! path that searches by exact name when the direct endpoint 404s)
//!
//! The official registry uses cursor pagination. We map our 1-indexed `page`
//! parameter onto it by treating `page == 1` as "no cursor" and refusing
//! deeper pagination for now — the caller gets back the first page plus a
//! `total_pages` hint of `1`. Cursor-aware pagination is a follow-up.
//!
//! Auth: optional `MCP_OFFICIAL_REGISTRY_TOKEN` env var sent as bearer.
//!
//! [repo]: https://github.com/modelcontextprotocol/registry
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use serde_json::Value;
use crate::openhuman::config::Config;
use super::super::store;
use super::super::types::{SmitheryConnection, SmitheryServerDetail, SmitheryServerSummary};
use super::{Registry, SOURCE_MCP_OFFICIAL};
const DEFAULT_BASE: &str = "https://registry.modelcontextprotocol.io";
pub struct McpOfficialRegistry;
#[async_trait]
impl Registry for McpOfficialRegistry {
fn source(&self) -> &'static str {
SOURCE_MCP_OFFICIAL
}
async fn search(
&self,
config: &Config,
query: Option<&str>,
page: u32,
page_size: u32,
) -> Result<(Vec<SmitheryServerSummary>, u32)> {
let q = query.unwrap_or("").trim();
let limit = page_size.max(1);
let cache_key = format!("mcp_official:search:{q}:{page}:{limit}");
if let Ok(Some(cached_body)) = store::get_cached(config, &cache_key) {
tracing::debug!("[mcp-official] search cache hit key={cache_key}");
if let Ok(parsed) = serde_json::from_str::<OfficialListResponse>(&cached_body) {
return Ok((parsed.into_summaries(), 1));
}
}
if page > 1 {
// Cursor pagination not yet wired up — return empty for page > 1
// so the UI doesn't loop fetching nonexistent pages.
tracing::debug!(
"[mcp-official] search returning empty for page>1 \
(cursor pagination not implemented)"
);
return Ok((Vec::new(), 1));
}
tracing::debug!("[mcp-official] search fetching q={q:?} limit={limit}");
let client = http_client()?;
let url = format!("{}/v0/servers", base_url());
let mut req = client.get(&url).header("Accept", "application/json");
if !q.is_empty() {
req = req.query(&[("search", q)]);
}
req = req.query(&[("limit", &limit.to_string())]);
req = apply_auth(req);
let resp = req.send().await.context("MCP official search failed")?;
let status = resp.status();
let body = resp.text().await.context("MCP official read failed")?;
if !status.is_success() {
tracing::warn!("[mcp-official] search HTTP {status} for key={cache_key}");
anyhow::bail!(
"MCP official registry returned HTTP {status}: {}",
&body[..body.len().min(200)]
);
}
let parsed: OfficialListResponse = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse MCP official response: {body}"))?;
let summaries = parsed.into_summaries();
let _ = store::set_cached(config, &cache_key, &body);
tracing::debug!(
"[mcp-official] search ok servers={} (cursor pagination not wired)",
summaries.len()
);
Ok((summaries, 1))
}
async fn get(&self, config: &Config, qualified_name: &str) -> Result<SmitheryServerDetail> {
let cache_key = format!("mcp_official:detail:{qualified_name}");
if let Ok(Some(cached_body)) = store::get_cached(config, &cache_key) {
tracing::debug!("[mcp-official] get cache hit qualified_name={qualified_name}");
if let Ok(server) = serde_json::from_str::<OfficialServer>(&cached_body) {
return Ok(server.into_detail());
}
}
let client = http_client()?;
let url = format!(
"{}/v0/servers/{}",
base_url(),
urlencoding_encode(qualified_name)
);
tracing::debug!("[mcp-official] get fetching {url}");
let req = apply_auth(client.get(&url).header("Accept", "application/json"));
let resp = req.send().await.context("MCP official get failed")?;
let status = resp.status();
let body = resp.text().await.context("MCP official read failed")?;
if !status.is_success() {
anyhow::bail!(
"MCP official registry GET {qualified_name} returned HTTP {status}: {}",
&body[..body.len().min(200)]
);
}
let server: OfficialServer = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse MCP official detail: {body}"))?;
let _ = store::set_cached(config, &cache_key, &body);
Ok(server.into_detail())
}
}
fn http_client() -> Result<Client> {
Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.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())
.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())
}
fn apply_auth(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(token) = auth_token() {
builder.bearer_auth(token)
} else {
builder
}
}
fn urlencoding_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'@' => {
out.push(b as char)
}
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}
// ── Wire-shape DTOs (best-effort against the official OpenAPI) ───────────────
//
// The official registry OpenAPI evolves; these are deliberately permissive
// (every nested field is optional) so a schema bump doesn't break parsing.
#[derive(Debug, Clone, Deserialize)]
struct OfficialListResponse {
#[serde(default)]
servers: Vec<OfficialServer>,
#[serde(default)]
#[allow(dead_code)]
metadata: Option<Value>,
}
impl OfficialListResponse {
fn into_summaries(self) -> Vec<SmitheryServerSummary> {
self.servers.into_iter().map(|s| s.into_summary()).collect()
}
}
#[derive(Debug, Clone, Deserialize)]
struct OfficialServer {
/// Reverse-DNS-style identifier, e.g. `io.github.foo/server-bar`.
#[serde(default)]
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default, rename = "iconUrl")]
icon_url: Option<String>,
/// Remote (HTTP / SSE) endpoints exposed by this server.
#[serde(default)]
remotes: Vec<OfficialRemote>,
/// Installable subprocess packages (npm, pip, brew, …).
#[serde(default)]
packages: Vec<OfficialPackage>,
}
impl OfficialServer {
fn into_summary(self) -> SmitheryServerSummary {
SmitheryServerSummary {
qualified_name: self.name.clone(),
display_name: self.name.clone(),
description: self.description.clone(),
icon_url: self.icon_url.clone(),
use_count: 0,
is_deployed: !self.remotes.is_empty(),
source: SOURCE_MCP_OFFICIAL.to_string(),
extra: std::collections::HashMap::new(),
}
}
fn into_detail(self) -> SmitheryServerDetail {
let mut connections: Vec<SmitheryConnection> = Vec::new();
for r in &self.remotes {
connections.push(SmitheryConnection {
r#type: "http".to_string(),
deployment_url: r.url.clone(),
config_schema: None,
example_config: None,
published: true,
extra: std::collections::HashMap::new(),
});
}
for p in &self.packages {
connections.push(SmitheryConnection {
r#type: "stdio".to_string(),
deployment_url: None,
config_schema: p.config_schema.clone(),
example_config: None,
published: true,
extra: std::collections::HashMap::new(),
});
}
SmitheryServerDetail {
qualified_name: self.name.clone(),
display_name: self.name.clone(),
description: self.description.clone(),
icon_url: self.icon_url.clone(),
connections,
source: SOURCE_MCP_OFFICIAL.to_string(),
extra: std::collections::HashMap::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct OfficialRemote {
#[serde(default)]
url: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct OfficialPackage {
#[serde(default, rename = "configSchema")]
config_schema: Option<Value>,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn official_server_into_summary_uses_name_as_qualified() {
let s: OfficialServer = serde_json::from_value(json!({
"name": "io.github.example/server",
"description": "Example",
}))
.unwrap();
let sum = s.into_summary();
assert_eq!(sum.qualified_name, "io.github.example/server");
assert_eq!(sum.source, SOURCE_MCP_OFFICIAL);
}
#[test]
fn list_response_tolerates_missing_metadata() {
let raw = json!({ "servers": [] });
let parsed: OfficialListResponse = serde_json::from_value(raw).unwrap();
assert!(parsed.servers.is_empty());
}
}
@@ -0,0 +1,71 @@
//! Upstream MCP registries. Today: Smithery.ai and the official
//! [modelcontextprotocol/registry](https://github.com/modelcontextprotocol/registry).
//!
//! All registries implement the [`Registry`] trait and return results in the
//! canonical [`super::types::SmitheryServerSummary`] /
//! [`super::types::SmitheryServerDetail`] shapes (named after Smithery for
//! backwards compatibility with the existing wire contract — non-Smithery
//! registries adapt their responses into the same shape and tag the `source`
//! field so the frontend can render provenance).
//!
//! [`enabled_registries`] returns every registry that should participate in a
//! query. Today both registries are always enabled; this will become
//! config-driven once the wider scope lands.
use anyhow::Result;
use async_trait::async_trait;
use crate::openhuman::config::Config;
use super::types::{SmitheryServerDetail, SmitheryServerSummary};
pub mod mcp_official;
pub mod smithery;
/// Canonical id for an upstream registry. Echoed back in
/// [`SmitheryServerSummary::source`] / [`SmitheryServerDetail::source`].
pub const SOURCE_SMITHERY: &str = "smithery";
pub const SOURCE_MCP_OFFICIAL: &str = "mcp_official";
/// An upstream MCP server directory.
#[async_trait]
pub trait Registry: Send + Sync {
/// Canonical identifier (see `SOURCE_*` constants). Returned on every
/// result so the frontend can attribute and the install path can route
/// `registry_get` back to the correct upstream.
fn source(&self) -> &'static str;
/// Search the registry. `page` is 1-indexed; registries that use
/// cursor-based pagination map their own cursor space onto page numbers
/// internally.
///
/// Returns `(servers, total_pages_known)`. `total_pages_known` is the
/// best-effort upper bound — registries that can't compute it report
/// the current page number.
async fn search(
&self,
config: &Config,
query: Option<&str>,
page: u32,
page_size: u32,
) -> Result<(Vec<SmitheryServerSummary>, u32)>;
/// Fetch one server's full detail by qualified name.
async fn get(&self, config: &Config, qualified_name: &str) -> Result<SmitheryServerDetail>;
}
/// All registries currently enabled for the user. Today: Smithery + official.
pub fn enabled_registries() -> Vec<Box<dyn Registry>> {
vec![
Box::new(smithery::SmitheryRegistry),
Box::new(mcp_official::McpOfficialRegistry),
]
}
/// Resolve a registry by [`Registry::source`] id. Used by `registry_get` to
/// route a fetch back to the upstream that produced the qualified name.
pub fn registry_for_source(source: &str) -> Option<Box<dyn Registry>> {
enabled_registries()
.into_iter()
.find(|r| r.source() == source)
}
@@ -0,0 +1,214 @@
//! [Smithery.ai](https://smithery.ai) MCP registry adapter.
//!
//! Base URL: `https://registry.smithery.ai`
//! Public endpoints:
//! `GET /servers?q=<query>&page=N&pageSize=M` → `SmitheryListResponse`
//! `GET /servers/{qualifiedName}` → `SmitheryServerDetail`
//!
//! Results are cached in SQLite for 10 minutes (TTL controlled in `store.rs`).
//! Auth: optional `SMITHERY_API_KEY` env var sent as `Authorization: Bearer`.
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::Client;
use crate::openhuman::config::Config;
use super::super::store;
use super::super::types::{SmitheryListResponse, SmitheryServerDetail, SmitheryServerSummary};
use super::{Registry, SOURCE_SMITHERY};
const SMITHERY_BASE: &str = "https://registry.smithery.ai";
const DEFAULT_PAGE_SIZE: u32 = 20;
pub struct SmitheryRegistry;
#[async_trait]
impl Registry for SmitheryRegistry {
fn source(&self) -> &'static str {
SOURCE_SMITHERY
}
async fn search(
&self,
config: &Config,
query: Option<&str>,
page: u32,
page_size: u32,
) -> Result<(Vec<SmitheryServerSummary>, u32)> {
let page = page.max(1);
let page_size = if page_size == 0 {
DEFAULT_PAGE_SIZE
} else {
page_size
};
let q = query.unwrap_or("").trim();
let cache_key = format!("smithery:search:{q}:{page}:{page_size}");
if let Ok(Some(cached_body)) = store::get_cached(config, &cache_key) {
tracing::debug!("[smithery] search cache hit key={cache_key}");
if let Ok(resp) = serde_json::from_str::<SmitheryListResponse>(&cached_body) {
return Ok((tag_source(resp.servers), resp.pagination.total_pages));
}
}
tracing::debug!("[smithery] search fetching q={q:?} page={page} page_size={page_size}");
let client = http_client()?;
let mut req = client.get(format!("{SMITHERY_BASE}/servers"));
if !q.is_empty() {
req = req.query(&[("q", q)]);
}
req = req
.query(&[
("page", &page.to_string()),
("pageSize", &page_size.to_string()),
])
.header("Accept", "application/json");
req = apply_auth(req);
let resp = req.send().await.context("Smithery search request failed")?;
let status = resp.status();
let body = resp.text().await.context("Smithery search read failed")?;
if !status.is_success() {
tracing::warn!("[smithery] search HTTP {status} for key={cache_key}");
anyhow::bail!(
"Smithery returned HTTP {status}: {}",
&body[..body.len().min(200)]
);
}
let parsed: SmitheryListResponse = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse Smithery list response: {body}"))?;
let total_pages = parsed.pagination.total_pages;
let servers = tag_source(parsed.servers);
let _ = store::set_cached(config, &cache_key, &body);
tracing::debug!(
"[smithery] search ok servers={} total_pages={}",
servers.len(),
total_pages
);
Ok((servers, total_pages))
}
async fn get(&self, config: &Config, qualified_name: &str) -> Result<SmitheryServerDetail> {
let cache_key = format!("smithery:detail:{qualified_name}");
if let Ok(Some(cached_body)) = store::get_cached(config, &cache_key) {
tracing::debug!("[smithery] get cache hit qualified_name={qualified_name}");
if let Ok(mut detail) = serde_json::from_str::<SmitheryServerDetail>(&cached_body) {
if detail.source.is_empty() {
detail.source = SOURCE_SMITHERY.to_string();
}
return Ok(detail);
}
}
tracing::debug!("[smithery] get fetching qualified_name={qualified_name}");
let client = http_client()?;
let url = format!(
"{SMITHERY_BASE}/servers/{}",
urlencoding_encode(qualified_name)
);
let req = apply_auth(client.get(&url).header("Accept", "application/json"));
let resp = req.send().await.context("Smithery get request failed")?;
let status = resp.status();
let body = resp.text().await.context("Smithery get read failed")?;
if !status.is_success() {
anyhow::bail!(
"Smithery GET {qualified_name} returned HTTP {status}: {}",
&body[..body.len().min(200)]
);
}
let mut detail: SmitheryServerDetail = serde_json::from_str(&body)
.with_context(|| format!("Failed to parse Smithery detail: {body}"))?;
detail.source = SOURCE_SMITHERY.to_string();
let _ = store::set_cached(config, &cache_key, &body);
tracing::debug!(
"[smithery] get ok qualified_name={qualified_name} connections={}",
detail.connections.len()
);
Ok(detail)
}
}
fn http_client() -> Result<Client> {
Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.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())
}
fn apply_auth(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(key) = smithery_api_key() {
builder.bearer_auth(key)
} else {
builder
}
}
fn tag_source(mut servers: Vec<SmitheryServerSummary>) -> Vec<SmitheryServerSummary> {
for s in &mut servers {
if s.source.is_empty() {
s.source = SOURCE_SMITHERY.to_string();
}
}
servers
}
/// Minimal URL percent-encoding for path segments (encodes `/` and common specials).
fn urlencoding_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'@' => {
out.push(b as char)
}
_ => {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urlencoding_encode_handles_at_sign_and_slash() {
let encoded = urlencoding_encode("@modelcontextprotocol/server-filesystem");
assert!(encoded.contains('%'), "slash should be encoded: {encoded}");
assert!(encoded.contains('@'), "@ should be preserved: {encoded}");
}
#[test]
fn urlencoding_encode_plain_ascii_unchanged() {
assert_eq!(urlencoding_encode("simple-name"), "simple-name");
}
#[test]
fn urlencoding_encode_space_becomes_percent_20() {
let encoded = urlencoding_encode("hello world");
assert_eq!(encoded, "hello%20world");
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Multi-registry dispatch entry point.
//!
//! `registry_search` fans out to every registry in
//! [`super::registries::enabled_registries`], runs them in parallel, and
//! returns merged results (failed registries are logged and skipped so one
//! flaky upstream doesn't blank the UI).
//!
//! `registry_get` routes by [`super::types::SmitheryServerDetail::source`].
//! The caller can pass an explicit source prefix using
//! `"<source>::<qualified_name>"` (e.g. `"mcp_official::io.github.foo/bar"`).
//! Without a prefix we ask every registry and return the first hit.
use anyhow::Result;
use futures::future::join_all;
use crate::openhuman::config::Config;
use super::registries::{enabled_registries, registry_for_source};
use super::types::{SmitheryServerDetail, SmitheryServerSummary};
const SOURCE_SEPARATOR: &str = "::";
/// Search every enabled registry in parallel; merge results. `total_pages`
/// is the max page count reported across registries (best-effort upper
/// bound).
pub async fn registry_search(
config: &Config,
query: Option<&str>,
page: u32,
page_size: u32,
) -> Result<(Vec<SmitheryServerSummary>, u32)> {
let registries = enabled_registries();
let queries = registries
.iter()
.map(|r| r.search(config, query, page, page_size));
let results = join_all(queries).await;
let mut merged: Vec<SmitheryServerSummary> = Vec::new();
let mut total_pages: u32 = 0;
for (idx, res) in results.into_iter().enumerate() {
let source = registries[idx].source();
match res {
Ok((mut servers, pages)) => {
tracing::debug!(
"[mcp-registry] {source} search ok servers={} pages={pages}",
servers.len()
);
merged.append(&mut servers);
total_pages = total_pages.max(pages);
}
Err(err) => {
tracing::warn!("[mcp-registry] {source} search failed: {err}");
}
}
}
if total_pages == 0 {
total_pages = page.max(1);
}
Ok((merged, total_pages))
}
/// Fetch a server detail. If `qualified_name` starts with `"<source>::"` we
/// route directly to that registry; otherwise every enabled registry is
/// tried in order and the first success wins.
pub async fn registry_get(config: &Config, qualified_name: &str) -> Result<SmitheryServerDetail> {
if let Some((source, rest)) = qualified_name.split_once(SOURCE_SEPARATOR) {
if let Some(registry) = registry_for_source(source) {
tracing::debug!("[mcp-registry] get routed source={source} qualified={rest}");
return registry.get(config, rest).await;
}
tracing::warn!(
"[mcp-registry] get: unknown source prefix {source:?} — falling back to all registries"
);
}
let mut last_err: Option<anyhow::Error> = None;
for registry in enabled_registries() {
match registry.get(config, qualified_name).await {
Ok(detail) => return Ok(detail),
Err(err) => {
tracing::debug!(
"[mcp-registry] {} get miss for {qualified_name}: {err}",
registry.source()
);
last_err = Some(err);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no registries enabled")))
}
@@ -26,6 +26,13 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
schemas("status"),
schemas("tool_call"),
schemas("config_assist"),
// Setup-agent surface (mcp_setup namespace, lives in setup_ops.rs).
setup_schemas("search"),
setup_schemas("get"),
setup_schemas("request_secret"),
setup_schemas("submit_secret"),
setup_schemas("test_connection"),
setup_schemas("install_and_connect"),
]
}
@@ -71,6 +78,30 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
schema: schemas("config_assist"),
handler: handle_config_assist,
},
RegisteredController {
schema: setup_schemas("search"),
handler: handle_setup_search,
},
RegisteredController {
schema: setup_schemas("get"),
handler: handle_setup_get,
},
RegisteredController {
schema: setup_schemas("request_secret"),
handler: handle_setup_request_secret,
},
RegisteredController {
schema: setup_schemas("submit_secret"),
handler: handle_setup_submit_secret,
},
RegisteredController {
schema: setup_schemas("test_connection"),
handler: handle_setup_test_connection,
},
RegisteredController {
schema: setup_schemas("install_and_connect"),
handler: handle_setup_install_and_connect,
},
]
}
@@ -368,6 +399,15 @@ pub fn schemas(function: &str) -> ControllerSchema {
],
},
// Handled by setup_schemas() — surface a clearer error rather than
// falling through to the generic unknown sink.
"setup_search"
| "setup_get"
| "setup_request_secret"
| "setup_submit_secret"
| "setup_test_connection"
| "setup_install_and_connect" => setup_schemas(function.trim_start_matches("setup_")),
_other => ControllerSchema {
namespace: "mcp_clients",
function: "unknown",
@@ -397,7 +437,7 @@ fn handle_registry_search(params: Map<String, Value>) -> ControllerFuture {
let page = read_optional_u32(&params, "page")?;
let page_size = read_optional_u32(&params, "page_size")?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_registry_search(
crate::openhuman::mcp_registry::ops::mcp_clients_registry_search(
&config, query, page, page_size,
)
.await?,
@@ -410,7 +450,7 @@ fn handle_registry_get(params: Map<String, Value>) -> ControllerFuture {
let config = config_rpc::load_config_with_timeout().await?;
let qualified_name = read_required::<String>(&params, "qualified_name")?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_registry_get(&config, qualified_name)
crate::openhuman::mcp_registry::ops::mcp_clients_registry_get(&config, qualified_name)
.await?,
)
})
@@ -420,7 +460,7 @@ fn handle_installed_list(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_clients::ops::mcp_clients_installed_list(&config).await?)
to_json(crate::openhuman::mcp_registry::ops::mcp_clients_installed_list(&config).await?)
})
}
@@ -431,7 +471,7 @@ fn handle_install(params: Map<String, Value>) -> ControllerFuture {
let env = read_required::<std::collections::HashMap<String, String>>(&params, "env")?;
let config_value = read_optional_json(&params, "config")?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_install(
crate::openhuman::mcp_registry::ops::mcp_clients_install(
&config,
qualified_name,
env,
@@ -447,7 +487,7 @@ fn handle_uninstall(params: Map<String, Value>) -> ControllerFuture {
let config = config_rpc::load_config_with_timeout().await?;
let server_id = read_required::<String>(&params, "server_id")?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_uninstall(&config, server_id).await?,
crate::openhuman::mcp_registry::ops::mcp_clients_uninstall(&config, server_id).await?,
)
})
}
@@ -456,14 +496,14 @@ fn handle_connect(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")?;
to_json(crate::openhuman::mcp_clients::ops::mcp_clients_connect(&config, server_id).await?)
to_json(crate::openhuman::mcp_registry::ops::mcp_clients_connect(&config, server_id).await?)
})
}
fn handle_disconnect(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let server_id = read_required::<String>(&params, "server_id")?;
to_json(crate::openhuman::mcp_clients::ops::mcp_clients_disconnect(server_id).await?)
to_json(crate::openhuman::mcp_registry::ops::mcp_clients_disconnect(server_id).await?)
})
}
@@ -471,7 +511,7 @@ fn handle_status(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_clients::ops::mcp_clients_status(&config).await?)
to_json(crate::openhuman::mcp_registry::ops::mcp_clients_status(&config).await?)
})
}
@@ -484,7 +524,7 @@ fn handle_tool_call(params: Map<String, Value>) -> ControllerFuture {
.cloned()
.unwrap_or(Value::Object(Map::new()));
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_tool_call(
crate::openhuman::mcp_registry::ops::mcp_clients_tool_call(
server_id, tool_name, arguments,
)
.await?,
@@ -497,11 +537,11 @@ fn handle_config_assist(params: Map<String, Value>) -> ControllerFuture {
let config = config_rpc::load_config_with_timeout().await?;
let qualified_name = read_required::<String>(&params, "qualified_name")?;
let user_message = read_required::<String>(&params, "user_message")?;
let history = read_optional::<Vec<crate::openhuman::mcp_clients::types::ChatTurn>>(
let history = read_optional::<Vec<crate::openhuman::mcp_registry::types::ChatTurn>>(
&params, "history",
)?;
to_json(
crate::openhuman::mcp_clients::ops::mcp_clients_config_assist(
crate::openhuman::mcp_registry::ops::mcp_clients_config_assist(
&config,
qualified_name,
user_message,
@@ -512,6 +552,331 @@ fn handle_config_assist(params: Map<String, Value>) -> ControllerFuture {
})
}
// ── mcp_setup_* schemas + handlers ────────────────────────────────────────────
/// All setup-agent schemas under the `mcp_setup` RPC namespace. Kept in a
/// separate function so the setup surface can evolve independently of the
/// existing `mcp_clients_*` controllers.
pub fn setup_schemas(function: &str) -> ControllerSchema {
match function {
"search" => ControllerSchema {
namespace: "mcp_setup",
function: "search",
description: "Search all enabled MCP registries (Smithery + official).",
inputs: vec![
FieldSchema {
name: "query",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Free-text search query.",
required: false,
},
FieldSchema {
name: "page",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "1-based page number (default: 1).",
required: false,
},
FieldSchema {
name: "page_size",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Results per page (default: 20).",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "servers",
ty: TypeSchema::Array(Box::new(TypeSchema::Ref("SmitheryServerSummary"))),
comment: "Merged summaries; each row tagged with its `source` (`smithery` | `mcp_official`).",
required: true,
},
FieldSchema {
name: "page",
ty: TypeSchema::U64,
comment: "Current page number.",
required: true,
},
FieldSchema {
name: "total_pages",
ty: TypeSchema::U64,
comment: "Upper-bound page count across registries.",
required: true,
},
],
},
"get" => ControllerSchema {
namespace: "mcp_setup",
function: "get",
description: "Fetch full details for one server. Adds `required_env_keys` derived from the connection schema.",
inputs: vec![FieldSchema {
name: "qualified_name",
ty: TypeSchema::String,
comment: "Registry qualified name. May be prefixed with `<source>::` to pin a registry.",
required: true,
}],
outputs: vec![FieldSchema {
name: "server",
ty: TypeSchema::Ref("SmitheryServerDetail"),
comment: "Full detail with `required_env_keys` injected.",
required: true,
}],
},
"request_secret" => ControllerSchema {
namespace: "mcp_setup",
function: "request_secret",
description: "Ask the user out-of-band for a secret value. Blocks until the UI submits via `submit_secret` (5-minute timeout). Returns an opaque ref; the raw value never enters the agent's context.",
inputs: vec![
FieldSchema {
name: "key_name",
ty: TypeSchema::String,
comment: "Display name of the env var (e.g. `NOTION_API_KEY`).",
required: true,
},
FieldSchema {
name: "prompt",
ty: TypeSchema::String,
comment: "Plain-English instruction shown to the user in the native input box.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "ref",
ty: TypeSchema::String,
comment: "Opaque handle like `secret://<hex>`. Pass back via `test_connection` / `install_and_connect`.",
required: true,
},
FieldSchema {
name: "key_name",
ty: TypeSchema::String,
comment: "Echoed key name.",
required: true,
},
],
},
"submit_secret" => ControllerSchema {
namespace: "mcp_setup",
function: "submit_secret",
description: "UI-side: fulfill a pending `request_secret` with the user-entered value. Not intended for agent use.",
inputs: vec![
FieldSchema {
name: "ref_id",
ty: TypeSchema::String,
comment: "The `secret://<hex>` ref returned by `request_secret`.",
required: true,
},
FieldSchema {
name: "value",
ty: TypeSchema::String,
comment: "Raw secret value. NEVER log this.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "ref",
ty: TypeSchema::String,
comment: "Echoed ref.",
required: true,
},
FieldSchema {
name: "fulfilled",
ty: TypeSchema::Bool,
comment: "True on success.",
required: true,
},
],
},
"test_connection" => ControllerSchema {
namespace: "mcp_setup",
function: "test_connection",
description: "Dry-run install: spawn a candidate server in a scratch process with the supplied secret refs, list its tools, tear down. Nothing persisted.",
inputs: vec![
FieldSchema {
name: "qualified_name",
ty: TypeSchema::String,
comment: "Registry qualified name.",
required: true,
},
FieldSchema {
name: "env_refs",
ty: TypeSchema::Map(Box::new(TypeSchema::String)),
comment: "Map `{ENV_KEY: secret://<hex>}` produced by `request_secret`.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "ok",
ty: TypeSchema::Bool,
comment: "True if initialize + tools/list succeeded.",
required: true,
},
FieldSchema {
name: "tools",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::Ref(
"McpRemoteTool",
))))),
comment: "Tools advertised by the candidate. Present iff `ok`.",
required: false,
},
FieldSchema {
name: "error",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Error string. Present iff `ok` is false.",
required: false,
},
],
},
"install_and_connect" => ControllerSchema {
namespace: "mcp_setup",
function: "install_and_connect",
description: "Commit: persist the install + secrets (consuming the refs), then connect immediately and return the tool list.",
inputs: vec![
FieldSchema {
name: "qualified_name",
ty: TypeSchema::String,
comment: "Registry qualified name.",
required: true,
},
FieldSchema {
name: "env_refs",
ty: TypeSchema::Map(Box::new(TypeSchema::String)),
comment: "Map `{ENV_KEY: secret://<hex>}`. Refs are consumed (removed from the in-memory map) on success.",
required: true,
},
],
outputs: vec![
FieldSchema {
name: "server_id",
ty: TypeSchema::String,
comment: "Freshly-minted server UUID.",
required: true,
},
FieldSchema {
name: "status",
ty: TypeSchema::String,
comment: "`connected` or `installed_disconnected` (install succeeded, connect failed).",
required: true,
},
FieldSchema {
name: "tools",
ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new(TypeSchema::Ref(
"McpTool",
))))),
comment: "Tool list iff `status == connected`.",
required: false,
},
FieldSchema {
name: "error",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Connect error iff `status != connected`.",
required: false,
},
],
},
_ => ControllerSchema {
namespace: "mcp_setup",
function: "unknown",
description: "Unknown mcp_setup controller function.",
inputs: vec![FieldSchema {
name: "function",
ty: TypeSchema::String,
comment: "Unknown function requested for schema lookup.",
required: true,
}],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Lookup error details.",
required: true,
}],
},
}
}
fn handle_setup_search(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let query = read_optional_string(&params, "query")?;
let page = read_optional_u32(&params, "page")?;
let page_size = read_optional_u32(&params, "page_size")?;
to_json(
crate::openhuman::mcp_registry::setup_ops::mcp_setup_search(
&config, query, page, page_size,
)
.await?,
)
})
}
fn handle_setup_get(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let qualified_name = read_required::<String>(&params, "qualified_name")?;
to_json(
crate::openhuman::mcp_registry::setup_ops::mcp_setup_get(&config, qualified_name)
.await?,
)
})
}
fn handle_setup_request_secret(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let key_name = read_required::<String>(&params, "key_name")?;
let prompt = read_required::<String>(&params, "prompt")?;
to_json(
crate::openhuman::mcp_registry::setup_ops::mcp_setup_request_secret(key_name, prompt)
.await?,
)
})
}
fn handle_setup_submit_secret(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let ref_id = read_required::<String>(&params, "ref_id")?;
let value = read_required::<String>(&params, "value")?;
to_json(
crate::openhuman::mcp_registry::setup_ops::mcp_setup_submit_secret(ref_id, value)
.await?,
)
})
}
fn handle_setup_test_connection(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let qualified_name = read_required::<String>(&params, "qualified_name")?;
let env_refs =
read_required::<std::collections::HashMap<String, String>>(&params, "env_refs")?;
to_json(
crate::openhuman::mcp_registry::setup_ops::mcp_setup_test_connection(
&config,
qualified_name,
env_refs,
)
.await?,
)
})
}
fn handle_setup_install_and_connect(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let qualified_name = read_required::<String>(&params, "qualified_name")?;
let env_refs =
read_required::<std::collections::HashMap<String, String>>(&params, "env_refs")?;
to_json(
crate::openhuman::mcp_registry::setup_ops::mcp_setup_install_and_connect(
&config,
qualified_name,
env_refs,
)
.await?,
)
})
}
// ── Param helpers ─────────────────────────────────────────────────────────────
fn read_required<T: DeserializeOwned>(params: &Map<String, Value>, key: &str) -> Result<T, String> {
@@ -644,21 +1009,36 @@ mod tests {
// ── all_controller_schemas / all_registered_controllers ────────────────────
#[test]
fn all_controller_schemas_covers_ten_methods() {
fn all_controller_schemas_covers_expected_methods() {
let schemas = all_controller_schemas();
assert_eq!(schemas.len(), 10);
// 10 mcp_clients + 6 mcp_setup
assert_eq!(schemas.len(), 16);
let mcp_clients_count = schemas
.iter()
.filter(|s| s.namespace == "mcp_clients")
.count();
let mcp_setup_count = schemas
.iter()
.filter(|s| s.namespace == "mcp_setup")
.count();
assert_eq!(mcp_clients_count, 10);
assert_eq!(mcp_setup_count, 6);
}
#[test]
fn all_registered_controllers_has_handler_per_schema() {
let controllers = all_registered_controllers();
assert_eq!(controllers.len(), 10);
assert_eq!(controllers.len(), 16);
}
#[test]
fn all_registered_controllers_all_use_mcp_clients_namespace() {
fn all_registered_controllers_use_expected_namespaces() {
for c in all_registered_controllers() {
assert_eq!(c.schema.namespace, "mcp_clients");
assert!(
matches!(c.schema.namespace, "mcp_clients" | "mcp_setup"),
"unexpected namespace {}",
c.schema.namespace
);
}
}
+327
View File
@@ -0,0 +1,327 @@
//! Opaque secret-ref machinery for the MCP setup agent.
//!
//! The setup agent must collect credentials from the user **without** the
//! raw values ever entering the LLM context. The flow is:
//!
//! 1. Agent calls `mcp_setup_request_secret(key_name, prompt)`. Core mints
//! a fresh [`SecretRef`] (`secret://<hex>`), publishes
//! [`crate::core::event_bus::DomainEvent::McpSetupSecretRequested`] so
//! the UI can render a native prompt, and **awaits** the user.
//! 2. UI prompts the user out-of-band and POSTs back via
//! `mcp_setup_submit_secret(ref_id, value)`. Core stores the raw value
//! against the ref and wakes the waiting `request_secret` call.
//! 3. Agent receives the ref and passes it into `mcp_setup_test_connection`
//! or `mcp_setup_install_and_connect`. Core resolves refs → values
//! just-in-time and either spawns a scratch subprocess (test) or
//! persists them into `mcp_client_env` (install).
//!
//! Raw values never enter or exit through the agent-facing tool calls.
//! The map is process-local and cleared on shutdown; values do not
//! persist across restarts unless committed via `consume_refs` →
//! `mcp_client_env`.
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use tokio::sync::{oneshot, Mutex};
use tokio::time::timeout;
/// How long an unfulfilled `request_secret` waits before giving up.
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(300); // 5 min
/// How long a fulfilled-but-unused secret hangs around before being purged.
/// Long enough to support iterative `test_connection` retries; short enough
/// that an abandoned conversation doesn't leave secrets stranded.
pub const IDLE_TTL: Duration = Duration::from_secs(900); // 15 min
// ── Types ────────────────────────────────────────────────────────────────────
/// `secret://<hex>` — opaque handle returned to the agent.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SecretRef(String);
impl SecretRef {
pub fn as_str(&self) -> &str {
&self.0
}
/// Parse an agent-supplied string. Accepts both bare hex and the
/// `secret://` prefixed form so callers can pass whatever they got
/// back from `request_secret`.
pub fn parse(s: &str) -> Option<Self> {
let trimmed = s.strip_prefix("secret://").unwrap_or(s).trim();
if trimmed.is_empty() || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
Some(Self(format!("secret://{trimmed}")))
}
fn mint() -> Self {
// 12 hex chars (48 bits) sourced from the first 6 bytes of a
// UUIDv4 (OsRng-backed). Short enough to log; collision-free in
// any sane setup-session window.
let raw = uuid::Uuid::new_v4().simple().to_string();
let hex = &raw[..12];
Self(format!("secret://{hex}"))
}
}
/// One entry in the global setup-secret map.
struct SecretEntry {
/// Display key name (e.g. `NOTION_API_KEY`). Safe to log; the *value*
/// is not. Returned to the UI in the request event.
key_name: String,
/// `None` while we're waiting for the UI to submit; `Some(value)`
/// once submitted.
value: Option<String>,
/// Wall-clock time the entry was last touched (created or fulfilled).
/// Used by the GC sweep to enforce `IDLE_TTL`.
last_touched: Instant,
/// Wakes the matching `request_secret` call once `value` is populated.
/// Taken (set to `None`) on first fulfillment so a double-submit is a
/// no-op rather than a panic.
waiter: Option<oneshot::Sender<()>>,
}
// ── Global registry ──────────────────────────────────────────────────────────
type Map = Arc<Mutex<HashMap<SecretRef, SecretEntry>>>;
static SETUP_SECRETS: OnceLock<Map> = OnceLock::new();
fn map() -> &'static Map {
SETUP_SECRETS.get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
}
// ── Public API ───────────────────────────────────────────────────────────────
/// Mint a new ref + parked waiter for `key_name`. Returns the ref and the
/// receiver the caller should `.await` on (with a timeout) until the UI
/// submits the value via [`fulfill`].
///
/// The waiter is taken out of the map; if `fulfill` arrives before the
/// caller awaits, the value is stashed in `entry.value` and the oneshot
/// fires immediately when awaited.
pub async fn mint_request(key_name: &str) -> (SecretRef, oneshot::Receiver<()>) {
let (tx, rx) = oneshot::channel();
let r = SecretRef::mint();
let entry = SecretEntry {
key_name: key_name.to_string(),
value: None,
last_touched: Instant::now(),
waiter: Some(tx),
};
map().lock().await.insert(r.clone(), entry);
tracing::debug!(
"[mcp-setup] minted ref={} key_name={}",
r.as_str(),
key_name
);
(r, rx)
}
/// UI-side: fulfill a pending ref with the raw value. Returns `true` if
/// the ref existed and was awaiting; `false` if it was unknown or already
/// fulfilled.
pub async fn fulfill(r: &SecretRef, value: String) -> bool {
let mut guard = map().lock().await;
let Some(entry) = guard.get_mut(r) else {
tracing::warn!("[mcp-setup] fulfill: unknown ref={}", r.as_str());
return false;
};
if entry.value.is_some() {
tracing::warn!("[mcp-setup] fulfill: double-submit ref={}", r.as_str());
return false;
}
entry.value = Some(value);
entry.last_touched = Instant::now();
if let Some(tx) = entry.waiter.take() {
let _ = tx.send(());
}
tracing::debug!("[mcp-setup] fulfilled ref={}", r.as_str());
true
}
/// Block on a freshly-minted request with the global timeout. On timeout
/// the entry is removed and `Err(_)` is returned.
pub async fn await_fulfillment(r: &SecretRef, rx: oneshot::Receiver<()>) -> anyhow::Result<()> {
match timeout(REQUEST_TIMEOUT, rx).await {
Ok(Ok(())) => Ok(()),
Ok(Err(_)) => {
// Sender dropped — usually means GC purged the entry. Surface
// as a timeout-style error to keep the caller simple.
let _ = forget(r).await;
anyhow::bail!("secret request {} cancelled before user submit", r.as_str())
}
Err(_) => {
let _ = forget(r).await;
anyhow::bail!(
"secret request {} timed out after {}s",
r.as_str(),
REQUEST_TIMEOUT.as_secs()
)
}
}
}
/// Resolve a `{KEY: SecretRef}` map into a `Vec<(KEY, VALUE)>`. Returns
/// `Err(_)` if any ref is unknown or not yet fulfilled — callers should
/// retry rather than partially-apply.
///
/// Touches the `last_touched` on every hit so iterative `test_connection`
/// calls reset the idle TTL.
pub async fn resolve_refs(
refs: &HashMap<String, SecretRef>,
) -> anyhow::Result<Vec<(String, String)>> {
let mut guard = map().lock().await;
let mut out = Vec::with_capacity(refs.len());
for (key, r) in refs {
let entry = guard
.get_mut(r)
.ok_or_else(|| anyhow::anyhow!("unknown secret ref {}", r.as_str()))?;
let value = entry
.value
.clone()
.ok_or_else(|| anyhow::anyhow!("secret ref {} not yet fulfilled", r.as_str()))?;
entry.last_touched = Instant::now();
out.push((key.clone(), value));
}
Ok(out)
}
/// Same as [`resolve_refs`] but also removes the entries from the map on
/// success. Used by `install_and_connect` once the values have been
/// persisted to `mcp_client_env`. On failure the entries are left intact
/// so the agent can retry without re-prompting.
pub async fn consume_refs(
refs: &HashMap<String, SecretRef>,
) -> anyhow::Result<Vec<(String, String)>> {
// First pass: resolve. Bail without mutation if any ref is missing.
let resolved = resolve_refs(refs).await?;
// Second pass: drop. Bail-out on the first miss is impossible because
// we just held the resolved values without releasing the lock — but to
// be honest we *did* release between the two awaits. Recheck.
let mut guard = map().lock().await;
for r in refs.values() {
guard.remove(r);
}
Ok(resolved)
}
/// Drop a single ref. Useful when the agent abandons a half-collected
/// install. Returns `true` if the ref existed.
pub async fn forget(r: &SecretRef) -> bool {
map().lock().await.remove(r).is_some()
}
/// Sweep entries idle longer than [`IDLE_TTL`]. Intended to be called from
/// a background task; cheap to call frequently. Returns the number of
/// entries reaped.
pub async fn gc_sweep() -> usize {
let now = Instant::now();
let mut guard = map().lock().await;
let before = guard.len();
guard.retain(|_, entry| now.duration_since(entry.last_touched) < IDLE_TTL);
let reaped = before - guard.len();
if reaped > 0 {
tracing::debug!("[mcp-setup] gc_sweep reaped={reaped}");
}
reaped
}
/// Test-only: inspect the key name for a ref. Production callers must
/// not learn this through the agent surface.
#[cfg(test)]
pub(crate) async fn key_name_for(r: &SecretRef) -> Option<String> {
map().lock().await.get(r).map(|e| e.key_name.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::Mutex as AsyncMutex;
// Setup tests share the static SETUP_SECRETS map. Serialise them via
// this guard so parallel runs don't trample each other.
static TEST_GUARD: AsyncMutex<()> = AsyncMutex::const_new(());
async fn clear_map() {
map().lock().await.clear();
}
#[tokio::test]
async fn mint_then_fulfill_then_resolve() {
let _g = TEST_GUARD.lock().await;
clear_map().await;
let (r, rx) = mint_request("API_KEY").await;
let r2 = r.clone();
let fulfill_task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(fulfill(&r2, "shh".into()).await);
});
await_fulfillment(&r, rx).await.expect("fulfilled");
fulfill_task.await.unwrap();
let mut refs = HashMap::new();
refs.insert("API_KEY".to_string(), r.clone());
let resolved = resolve_refs(&refs).await.expect("resolves");
assert_eq!(resolved, vec![("API_KEY".to_string(), "shh".to_string())]);
// Still present after resolve.
assert_eq!(key_name_for(&r).await.as_deref(), Some("API_KEY"));
}
#[tokio::test]
async fn consume_drops_entries() {
let _g = TEST_GUARD.lock().await;
clear_map().await;
let (r, _rx) = mint_request("TOKEN").await;
fulfill(&r, "v".into()).await;
let mut refs = HashMap::new();
refs.insert("TOKEN".to_string(), r.clone());
let _ = consume_refs(&refs).await.expect("consumes");
assert!(key_name_for(&r).await.is_none(), "consumed entry removed");
}
#[tokio::test]
async fn resolve_fails_when_not_fulfilled() {
let _g = TEST_GUARD.lock().await;
clear_map().await;
let (r, _rx) = mint_request("UNSET").await;
let mut refs = HashMap::new();
refs.insert("UNSET".to_string(), r);
assert!(resolve_refs(&refs).await.is_err());
}
#[tokio::test]
async fn resolve_fails_on_unknown_ref() {
let _g = TEST_GUARD.lock().await;
clear_map().await;
let fake = SecretRef::parse("secret://deadbeef").unwrap();
let mut refs = HashMap::new();
refs.insert("X".to_string(), fake);
assert!(resolve_refs(&refs).await.is_err());
}
#[tokio::test]
async fn double_fulfill_is_noop() {
let _g = TEST_GUARD.lock().await;
clear_map().await;
let (r, _rx) = mint_request("K").await;
assert!(fulfill(&r, "first".into()).await);
assert!(!fulfill(&r, "second".into()).await);
let mut refs = HashMap::new();
refs.insert("K".to_string(), r);
let resolved = resolve_refs(&refs).await.unwrap();
assert_eq!(resolved[0].1, "first", "second fulfill ignored");
}
#[tokio::test]
async fn parse_accepts_bare_and_prefixed_hex() {
assert!(SecretRef::parse("secret://abc123").is_some());
assert!(SecretRef::parse("abc123").is_some());
assert!(SecretRef::parse("not-hex").is_none());
assert!(SecretRef::parse("").is_none());
}
}
+329
View File
@@ -0,0 +1,329 @@
//! RPC handlers for the MCP setup agent. See `docs/MCP_SETUP_AGENT.md`.
//!
//! These handlers form the agent-facing tool surface:
//!
//! - `mcp_setup_search` / `mcp_setup_get` — thin wrappers over
//! [`super::registry`] so the agent browses upstream registries.
//! - `mcp_setup_request_secret` — block on a fresh ref until the UI
//! submits a value.
//! - `mcp_setup_submit_secret` — UI-side fulfillment.
//! - `mcp_setup_test_connection` — spawn a candidate subprocess in a
//! scratch workspace, list its tools, tear it down. No persistence.
//! - `mcp_setup_install_and_connect` — commit: persist install + env,
//! call [`super::connections::connect`].
//!
//! Raw secret values flow only through `submit_secret` and the
//! just-in-time resolve inside `test_connection` / `install_and_connect`.
//! They are never echoed in responses or logged.
use std::collections::HashMap;
use std::path::PathBuf;
use serde_json::{json, Value};
use uuid::Uuid;
use crate::core::event_bus::{publish_global, DomainEvent};
use crate::openhuman::config::Config;
use crate::openhuman::mcp_client::McpStdioClient;
use crate::rpc::RpcOutcome;
use super::ops::resolve_command;
use super::setup::{self, SecretRef};
use super::types::{CommandKind, InstalledServer};
use super::{connections, registry, store};
// ── search ───────────────────────────────────────────────────────────────────
pub async fn mcp_setup_search(
config: &Config,
query: Option<String>,
page: Option<u32>,
page_size: Option<u32>,
) -> Result<RpcOutcome<Value>, String> {
let page = page.unwrap_or(1);
let page_size = page_size.unwrap_or(20);
let (servers, total_pages) =
registry::registry_search(config, query.as_deref(), page, page_size)
.await
.map_err(|e| e.to_string())?;
Ok(RpcOutcome::new(
json!({ "servers": servers, "page": page, "total_pages": total_pages }),
vec![format!("setup_search returned {} servers", servers.len())],
))
}
// ── get ──────────────────────────────────────────────────────────────────────
pub async fn mcp_setup_get(
config: &Config,
qualified_name: String,
) -> Result<RpcOutcome<Value>, String> {
let q = qualified_name.trim();
if q.is_empty() {
return Err("qualified_name must not be empty".to_string());
}
let detail = registry::registry_get(config, q)
.await
.map_err(|e| e.to_string())?;
let required_env_keys = collect_required_env_keys(&detail);
let mut value = serde_json::to_value(&detail).map_err(|e| format!("ser: {e}"))?;
if let Some(obj) = value.as_object_mut() {
obj.insert("required_env_keys".into(), json!(required_env_keys));
}
Ok(RpcOutcome::new(
json!({ "server": value }),
vec![format!("setup_get ok qualified_name={q}")],
))
}
// ── request_secret ───────────────────────────────────────────────────────────
pub async fn mcp_setup_request_secret(
key_name: String,
prompt: String,
) -> Result<RpcOutcome<Value>, String> {
let key_name = key_name.trim().to_string();
let prompt = prompt.trim().to_string();
if key_name.is_empty() {
return Err("key_name must not be empty".to_string());
}
if prompt.is_empty() {
return Err("prompt must not be empty".to_string());
}
let (r, rx) = setup::mint_request(&key_name).await;
let _ = publish_global(DomainEvent::McpSetupSecretRequested {
ref_id: r.as_str().to_string(),
key_name: key_name.clone(),
prompt: prompt.clone(),
});
tracing::info!(
"[mcp-setup] request_secret ref={} key_name={} (awaiting UI submit)",
r.as_str(),
key_name
);
setup::await_fulfillment(&r, rx)
.await
.map_err(|e| e.to_string())?;
tracing::info!("[mcp-setup] request_secret fulfilled ref={}", r.as_str());
Ok(RpcOutcome::new(
json!({ "ref": r.as_str(), "key_name": key_name }),
vec![format!("collected secret for key={key_name}")],
))
}
// ── submit_secret (UI side) ──────────────────────────────────────────────────
pub async fn mcp_setup_submit_secret(
ref_id: String,
value: String,
) -> Result<RpcOutcome<Value>, String> {
let r = SecretRef::parse(&ref_id).ok_or_else(|| format!("invalid ref_id `{ref_id}`"))?;
let ok = setup::fulfill(&r, value).await;
if !ok {
return Err(format!("ref {} unknown or already submitted", r.as_str()));
}
Ok(RpcOutcome::new(
json!({ "ref": r.as_str(), "fulfilled": true }),
vec![format!("submitted secret for ref={}", r.as_str())],
))
}
// ── test_connection ──────────────────────────────────────────────────────────
pub async fn mcp_setup_test_connection(
config: &Config,
qualified_name: String,
env_refs: HashMap<String, String>,
) -> Result<RpcOutcome<Value>, String> {
let q = qualified_name.trim();
if q.is_empty() {
return Err("qualified_name must not be empty".to_string());
}
let parsed_refs = parse_ref_map(env_refs)?;
let env = setup::resolve_refs(&parsed_refs)
.await
.map_err(|e| e.to_string())?;
let detail = registry::registry_get(config, q)
.await
.map_err(|e| e.to_string())?;
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"));
let (_kind, command, args) = resolve_command(q, stdio_conn);
let identity = config.mcp_client.client_identity.clone();
let cwd: Option<PathBuf> = None;
let client = McpStdioClient::new(command.clone(), args.clone(), env, cwd, identity);
// Scratch subprocess — initialise + list_tools, then close. Nothing
// persisted. Errors bubble up so the agent can show them to the user.
if let Err(err) = client.initialize().await {
return Ok(RpcOutcome::new(
json!({ "ok": false, "error": err.to_string() }),
vec![format!("test_connection failed for {q}: {err}")],
));
}
let tools = match client.list_tools().await {
Ok(t) => t,
Err(err) => {
let _ = client.close_session().await;
return Ok(RpcOutcome::new(
json!({ "ok": false, "error": err.to_string() }),
vec![format!("test_connection list_tools failed for {q}: {err}")],
));
}
};
let _ = client.close_session().await;
let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
Ok(RpcOutcome::new(
json!({ "ok": true, "tools": tools }),
vec![format!(
"test_connection ok for {q}: {} tools ({:?})",
tools.len(),
names
)],
))
}
// ── install_and_connect ──────────────────────────────────────────────────────
pub async fn mcp_setup_install_and_connect(
config: &Config,
qualified_name: String,
env_refs: HashMap<String, String>,
) -> Result<RpcOutcome<Value>, String> {
let q = qualified_name.trim();
if q.is_empty() {
return Err("qualified_name must not be empty".to_string());
}
let parsed_refs = parse_ref_map(env_refs)?;
let detail = registry::registry_get(config, q)
.await
.map_err(|e| e.to_string())?;
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"));
let (command_kind, command, args) = resolve_command(q, stdio_conn);
// Consume refs only after `registry_get` succeeds — that way a
// misconfigured server name doesn't burn the user's collected
// secrets.
let env_pairs = setup::consume_refs(&parsed_refs)
.await
.map_err(|e| e.to_string())?;
let env_map: HashMap<String, String> = env_pairs.into_iter().collect();
let server_id = Uuid::new_v4().to_string();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let env_keys: Vec<String> = env_map.keys().cloned().collect();
let server = InstalledServer {
server_id: server_id.clone(),
qualified_name: q.to_string(),
display_name: detail.display_name.clone(),
description: detail.description.clone(),
icon_url: detail.icon_url.clone(),
command_kind,
command,
args,
env_keys,
config: None,
installed_at: now_ms,
last_connected_at: None,
};
store::insert_server(config, &server).map_err(|e| e.to_string())?;
store::set_env_values(config, &server_id, &env_map).map_err(|e| e.to_string())?;
let _ = publish_global(DomainEvent::McpServerInstalled {
server_id: server_id.clone(),
qualified_name: server.qualified_name.clone(),
});
// Connect immediately so the agent gets the tool list in the same
// response. A connect failure does not roll back the install — the
// user can retry via `mcp_clients_connect` later.
match connections::connect(config, &server).await {
Ok(tools) => Ok(RpcOutcome::new(
json!({
"server_id": server_id,
"status": "connected",
"tools": tools,
}),
vec![format!(
"install_and_connect ok server_id={server_id} tools={}",
tools.len()
)],
)),
Err(err) => Ok(RpcOutcome::new(
json!({
"server_id": server_id,
"status": "installed_disconnected",
"error": err.to_string(),
}),
vec![format!(
"install_and_connect installed server_id={server_id} \
but connect failed: {err}"
)],
)),
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
fn parse_ref_map(raw: HashMap<String, String>) -> Result<HashMap<String, SecretRef>, String> {
let mut out = HashMap::with_capacity(raw.len());
for (k, v) in raw {
let r = SecretRef::parse(&v)
.ok_or_else(|| format!("env_refs[{k}] is not a valid secret ref"))?;
out.insert(k, r);
}
Ok(out)
}
/// Best-effort scan of a Smithery `config_schema` for required env keys.
/// Mirrors the legacy helper in `ops.rs` so the setup agent does not
/// depend on its private wiring.
fn collect_required_env_keys(detail: &super::types::SmitheryServerDetail) -> Vec<String> {
let mut keys = Vec::new();
for conn in &detail.connections {
if conn.r#type != "stdio" {
continue;
}
let Some(schema) = conn.config_schema.as_ref() else {
continue;
};
let Some(props) = schema.get("properties").and_then(Value::as_object) else {
continue;
};
for k in props.keys() {
if !keys.contains(k) {
keys.push(k.clone());
}
}
}
keys
}
// Compile-time anchor so a missing CommandKind import surfaces here, not
// at the call site.
#[allow(dead_code)]
const _: Option<CommandKind> = None;
@@ -134,6 +134,12 @@ pub struct SmitheryServerSummary {
pub use_count: u64,
#[serde(default)]
pub is_deployed: bool,
/// Upstream registry id (`"smithery"` | `"mcp_official"`). Always set
/// by the dispatcher in `super::registries` so the frontend can attribute
/// rows and the install path can route `registry_get` back to the
/// originating upstream.
#[serde(default)]
pub source: String,
/// Raw extra fields preserved for future use.
#[serde(flatten, default)]
pub extra: std::collections::HashMap<String, Value>,
@@ -151,6 +157,9 @@ pub struct SmitheryServerDetail {
pub icon_url: Option<String>,
#[serde(default)]
pub connections: Vec<SmitheryConnection>,
/// Upstream registry id (`"smithery"` | `"mcp_official"`).
#[serde(default)]
pub source: String,
#[serde(flatten, default)]
pub extra: std::collections::HashMap<String, Value>,
}
+1 -1
View File
@@ -1,7 +1,7 @@
//! Streamable HTTP + SSE transport for the OpenHuman MCP server.
//!
//! Reuses [`super::protocol`] for JSON-RPC dispatch. Session lifecycle and header
//! names match [`crate::openhuman::mcp_client::client::McpHttpClient`] so remote
//! names match [`crate::openhuman::mcp_client::McpHttpClient`] so remote
//! MCP clients can talk to this server without custom glue.
use std::collections::HashMap;
+1 -1
View File
@@ -48,7 +48,7 @@ pub mod javascript;
pub mod keyring;
pub mod learning;
pub mod mcp_client;
pub mod mcp_clients;
pub mod mcp_registry;
pub mod mcp_server;
pub mod meet;
pub mod meet_agent;
+1 -1
View File
@@ -107,7 +107,7 @@ pub fn registry_entries() -> Vec<ToolRegistryEntry> {
// `block_in_place` requires the multi-threaded tokio runtime; fall back
// silently to an empty list in single-threaded contexts (e.g. unit tests).
let client_tools = {
use crate::openhuman::mcp_clients::connections;
use crate::openhuman::mcp_registry::connections;
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
// Only use block_in_place when we are on the multi-threaded
@@ -0,0 +1,393 @@
//! Agent-facing tool wrappers around `mcp_registry::setup_ops`.
//!
//! Six thin tools the `mcp_setup` sub-agent uses to walk the user
//! through installing an MCP server. They are intentionally simple —
//! the real logic lives in
//! [`crate::openhuman::mcp_registry::setup_ops`]; these structs only
//! marshall args ↔ `serde_json::Value` and turn `RpcOutcome` into a
//! `ToolResult`.
//!
//! Secret values **never** pass through these tools. `request_secret`
//! returns an opaque `secret://<hex>` ref; the agent stores the ref and
//! passes it back into `test_connection` / `install_and_connect` which
//! resolve it inside the core process.
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{json, Value};
use crate::openhuman::config::Config;
use crate::openhuman::mcp_registry::setup_ops;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult};
// ── Helpers ──────────────────────────────────────────────────────────────────
fn outcome_to_result(
outcome: Result<crate::rpc::RpcOutcome<Value>, String>,
) -> anyhow::Result<ToolResult> {
match outcome {
Ok(out) => Ok(ToolResult::json(out.value)),
Err(err) => Ok(ToolResult::error(err)),
}
}
fn read_str(args: &Value, key: &str) -> Result<String, String> {
args.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.ok_or_else(|| format!("missing required string `{key}`"))
}
fn read_str_opt(args: &Value, key: &str) -> Option<String> {
args.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.filter(|s| !s.is_empty())
}
fn read_u32_opt(args: &Value, key: &str) -> Option<u32> {
args.get(key).and_then(Value::as_u64).map(|v| v as u32)
}
fn read_str_map(args: &Value, key: &str) -> Result<HashMap<String, String>, String> {
let v = args
.get(key)
.ok_or_else(|| format!("missing required object `{key}`"))?;
let obj = v
.as_object()
.ok_or_else(|| format!("`{key}` must be an object"))?;
let mut out = HashMap::with_capacity(obj.len());
for (k, v) in obj {
let s = v
.as_str()
.ok_or_else(|| format!("`{key}[{k}]` must be a string"))?;
out.insert(k.clone(), s.to_string());
}
Ok(out)
}
// ── mcp_setup_search ─────────────────────────────────────────────────────────
pub struct McpSetupSearchTool {
config: Arc<Config>,
}
impl McpSetupSearchTool {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
}
#[async_trait]
impl Tool for McpSetupSearchTool {
fn name(&self) -> &str {
"mcp_setup_search"
}
fn description(&self) -> &str {
"Search all enabled MCP server registries (Smithery + modelcontextprotocol/registry). \
Returns merged results tagged with the upstream `source`. Use to discover candidate \
servers by keyword (e.g. 'notion', 'filesystem', 'github')."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Free-text search query." },
"page": { "type": "integer", "description": "1-based page number (default 1)." },
"page_size": { "type": "integer", "description": "Results per page (default 20)." }
}
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::ReadOnly
}
fn category(&self) -> ToolCategory {
ToolCategory::System
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let query = read_str_opt(&args, "query");
let page = read_u32_opt(&args, "page");
let page_size = read_u32_opt(&args, "page_size");
outcome_to_result(setup_ops::mcp_setup_search(&self.config, query, page, page_size).await)
}
}
// ── mcp_setup_get ────────────────────────────────────────────────────────────
pub struct McpSetupGetTool {
config: Arc<Config>,
}
impl McpSetupGetTool {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
}
#[async_trait]
impl Tool for McpSetupGetTool {
fn name(&self) -> &str {
"mcp_setup_get"
}
fn description(&self) -> &str {
"Fetch full detail for one MCP server, including the `required_env_keys` array derived \
from its connection schema. Use to plan which secrets to request from the user."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"qualified_name": {
"type": "string",
"description": "Registry qualified name (e.g. `@notion/server-notion`). \
May be prefixed with `<source>::` to pin a registry."
}
},
"required": ["qualified_name"]
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::ReadOnly
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let qualified_name = match read_str(&args, "qualified_name") {
Ok(v) => v,
Err(e) => return Ok(ToolResult::error(e)),
};
outcome_to_result(setup_ops::mcp_setup_get(&self.config, qualified_name).await)
}
}
// ── mcp_setup_request_secret ─────────────────────────────────────────────────
pub struct McpSetupRequestSecretTool;
impl McpSetupRequestSecretTool {
pub fn new() -> Self {
Self
}
}
impl Default for McpSetupRequestSecretTool {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Tool for McpSetupRequestSecretTool {
fn name(&self) -> &str {
"mcp_setup_request_secret"
}
fn description(&self) -> &str {
"Ask the user to provide a secret value (API key, OAuth token, etc.) via a native UI \
prompt. Returns an opaque ref like `secret://<hex>`. The raw value never enters this \
agent's context — only the ref does. Pass the ref back into `mcp_setup_test_connection` \
or `mcp_setup_install_and_connect`. Blocks for up to 5 minutes waiting on the user."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"key_name": {
"type": "string",
"description": "Env-var name (e.g. `NOTION_API_KEY`). Shown to the user as the field label."
},
"prompt": {
"type": "string",
"description": "Plain-English explanation the user sees, e.g. 'Paste your Notion integration token from notion.so/my-integrations.'"
}
},
"required": ["key_name", "prompt"]
})
}
fn permission_level(&self) -> PermissionLevel {
// No filesystem / network — purely an IPC handshake. ReadOnly is
// wrong (it's user-input), but Write is too strong. The harness
// gates by `< Execute`, so ReadOnly keeps the agent able to call
// it.
PermissionLevel::ReadOnly
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let key_name = match read_str(&args, "key_name") {
Ok(v) => v,
Err(e) => return Ok(ToolResult::error(e)),
};
let prompt = match read_str(&args, "prompt") {
Ok(v) => v,
Err(e) => return Ok(ToolResult::error(e)),
};
outcome_to_result(setup_ops::mcp_setup_request_secret(key_name, prompt).await)
}
}
// ── mcp_setup_test_connection ────────────────────────────────────────────────
pub struct McpSetupTestConnectionTool {
config: Arc<Config>,
}
impl McpSetupTestConnectionTool {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
}
#[async_trait]
impl Tool for McpSetupTestConnectionTool {
fn name(&self) -> &str {
"mcp_setup_test_connection"
}
fn description(&self) -> &str {
"Dry-run install: spawn the candidate MCP server in a scratch process with the supplied \
secret refs, list its tools, tear it down. Nothing is persisted. Returns \
`{ ok: true, tools: [...] }` on success or `{ ok: false, error: ... }` on failure — \
use this to validate the user's secrets before committing."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"qualified_name": {
"type": "string",
"description": "Registry qualified name."
},
"env_refs": {
"type": "object",
"description": "Map `{ENV_KEY: \"secret://<hex>\"}` of refs collected from `mcp_setup_request_secret`.",
"additionalProperties": { "type": "string" }
}
},
"required": ["qualified_name", "env_refs"]
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Execute
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let qualified_name = match read_str(&args, "qualified_name") {
Ok(v) => v,
Err(e) => return Ok(ToolResult::error(e)),
};
let env_refs = match read_str_map(&args, "env_refs") {
Ok(v) => v,
Err(e) => return Ok(ToolResult::error(e)),
};
outcome_to_result(
setup_ops::mcp_setup_test_connection(&self.config, qualified_name, env_refs).await,
)
}
}
// ── mcp_setup_install_and_connect ────────────────────────────────────────────
pub struct McpSetupInstallAndConnectTool {
config: Arc<Config>,
}
impl McpSetupInstallAndConnectTool {
pub fn new(config: Arc<Config>) -> Self {
Self { config }
}
}
#[async_trait]
impl Tool for McpSetupInstallAndConnectTool {
fn name(&self) -> &str {
"mcp_setup_install_and_connect"
}
fn description(&self) -> &str {
"Commit: persist the MCP server install + the user's secrets (consuming the refs), then \
connect immediately. Returns the new `server_id` and (on success) the tool list now \
available to the agent. Only call after `mcp_setup_test_connection` returned ok."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"qualified_name": {
"type": "string",
"description": "Registry qualified name."
},
"env_refs": {
"type": "object",
"description": "Same shape as `mcp_setup_test_connection`. Refs are consumed (removed from the in-memory map) on success.",
"additionalProperties": { "type": "string" }
}
},
"required": ["qualified_name", "env_refs"]
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let qualified_name = match read_str(&args, "qualified_name") {
Ok(v) => v,
Err(e) => return Ok(ToolResult::error(e)),
};
let env_refs = match read_str_map(&args, "env_refs") {
Ok(v) => v,
Err(e) => return Ok(ToolResult::error(e)),
};
outcome_to_result(
setup_ops::mcp_setup_install_and_connect(&self.config, qualified_name, env_refs).await,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_are_stable() {
let cfg = Arc::new(Config::default());
assert_eq!(
McpSetupSearchTool::new(cfg.clone()).name(),
"mcp_setup_search"
);
assert_eq!(McpSetupGetTool::new(cfg.clone()).name(), "mcp_setup_get");
assert_eq!(
McpSetupRequestSecretTool::new().name(),
"mcp_setup_request_secret"
);
assert_eq!(
McpSetupTestConnectionTool::new(cfg.clone()).name(),
"mcp_setup_test_connection"
);
assert_eq!(
McpSetupInstallAndConnectTool::new(cfg).name(),
"mcp_setup_install_and_connect"
);
}
#[test]
fn read_str_map_rejects_non_string_values() {
let args = json!({ "env_refs": { "K": 42 } });
assert!(read_str_map(&args, "env_refs").is_err());
}
}
+5
View File
@@ -5,6 +5,7 @@ mod gitbooks;
mod gmail_unsubscribe;
mod http_request;
mod mcp;
mod mcp_setup;
mod polymarket;
mod polymarket_orders;
mod url_guard;
@@ -17,6 +18,10 @@ pub use gitbooks::{GitbooksGetPageTool, GitbooksSearchTool};
pub use gmail_unsubscribe::GmailUnsubscribeTool;
pub use http_request::HttpRequestTool;
pub use mcp::{McpCallTool, McpListServersTool, McpListToolsTool};
pub use mcp_setup::{
McpSetupGetTool, McpSetupInstallAndConnectTool, McpSetupRequestSecretTool, McpSetupSearchTool,
McpSetupTestConnectionTool,
};
pub use polymarket::PolymarketTool;
pub use web_fetch::WebFetchTool;
pub use web_search::WebSearchTool;
+14
View File
@@ -289,6 +289,20 @@ pub fn all_tools_with_runtime(
tracing::debug!("[gitbooks] registered gitbooks_search + gitbooks_get_page");
}
// MCP setup-agent tool surface (search/get/request_secret/test/install).
// Registered unconditionally — the `mcp_setup` sub-agent filters to just
// these via its `[tools] named = [...]` allowlist, and the host agent's
// own tool list is wide enough that the extra five entries are negligible.
{
let cfg = Arc::new(root_config.clone());
tools.push(Box::new(McpSetupSearchTool::new(Arc::clone(&cfg))));
tools.push(Box::new(McpSetupGetTool::new(Arc::clone(&cfg))));
tools.push(Box::new(McpSetupRequestSecretTool::new()));
tools.push(Box::new(McpSetupTestConnectionTool::new(Arc::clone(&cfg))));
tools.push(Box::new(McpSetupInstallAndConnectTool::new(cfg)));
tracing::debug!("[mcp_setup] registered 5 setup-agent tools");
}
// Generic remote MCP bridge tools. These let the agent enumerate
// named MCP servers and forward `tools/call` through the core
// instead of hardcoding one bespoke MCP integration per server.
+116
View File
@@ -0,0 +1,116 @@
//! End-to-end test for the `mcp_registry` connection lifecycle.
//!
//! Hermetic: spawns the `test-mcp-stub` binary (built alongside this test
//! by Cargo and exposed via `CARGO_BIN_EXE_test-mcp-stub`) as the MCP
//! subprocess. No npx, no network. Validates that
//! `store::insert_server` → `connections::connect` → `connections::call_tool`
//! → `connections::disconnect` round-trips correctly through the unified
//! `mcp_client::McpStdioClient` transport.
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::mcp_registry::connections;
use openhuman_core::openhuman::mcp_registry::store;
use openhuman_core::openhuman::mcp_registry::types::{CommandKind, InstalledServer};
fn fresh_workspace_config() -> (tempfile::TempDir, Config) {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
(tmp, cfg)
}
fn make_installed_server() -> InstalledServer {
let stub_path = env!("CARGO_BIN_EXE_test-mcp-stub");
InstalledServer {
server_id: format!("test-{}", uuid::Uuid::new_v4()),
qualified_name: "@openhuman-test/echo".to_string(),
display_name: "Test Echo".to_string(),
description: Some("Stub MCP server used by mcp_registry_e2e tests.".into()),
icon_url: None,
command_kind: CommandKind::Binary,
command: stub_path.to_string(),
args: Vec::new(),
env_keys: Vec::new(),
config: None,
installed_at: 0,
last_connected_at: None,
}
}
#[tokio::test]
async fn connect_lists_one_tool_then_disconnect() {
let (_tmp, cfg) = fresh_workspace_config();
let server = make_installed_server();
// Insert into the store so `all_status` (which reads from store) sees it,
// and so a follow-up `boot::spawn_installed_servers` would pick it up.
store::insert_server(&cfg, &server).expect("insert installed server");
// Connect: spawns the stub subprocess and runs `initialize` + `tools/list`.
let tools = connections::connect(&cfg, &server)
.await
.expect("connect succeeds");
assert_eq!(tools.len(), 1, "stub advertises one tool");
assert_eq!(tools[0].name, "echo");
assert!(tools[0].input_schema.is_object());
// Status reflects the live connection.
let statuses = connections::all_status(&cfg).await;
let mine = statuses
.iter()
.find(|s| s.server_id == server.server_id)
.expect("status entry present");
assert_eq!(mine.tool_count, 1);
// Call the `echo` tool and verify the response payload.
let result = connections::call_tool(
&server.server_id,
"echo",
serde_json::json!({ "message": "hello mcp" }),
)
.await
.expect("call_tool succeeds");
let text = result
.get("content")
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|first| first.get("text"))
.and_then(|t| t.as_str())
.unwrap_or("");
assert_eq!(text, "hello mcp", "echo tool returns the input verbatim");
// Disconnect: removes from the registry and closes the subprocess.
let removed = connections::disconnect(&server.server_id).await;
assert!(removed, "disconnect drops the live connection");
// Subsequent call fails because the server_id is no longer connected.
let err = connections::call_tool(
&server.server_id,
"echo",
serde_json::json!({ "message": "post-disconnect" }),
)
.await
.expect_err("call_tool fails after disconnect");
assert!(err.contains("not connected"));
}
#[tokio::test]
async fn unknown_tool_call_returns_error() {
let (_tmp, cfg) = fresh_workspace_config();
let server = make_installed_server();
store::insert_server(&cfg, &server).expect("insert installed server");
connections::connect(&cfg, &server).await.expect("connect");
let err = connections::call_tool(&server.server_id, "does_not_exist", serde_json::json!({}))
.await
.expect_err("stub rejects unknown tools");
assert!(
err.to_lowercase().contains("unknown tool") || err.contains("error"),
"expected unknown-tool error, got: {err}"
);
let _ = connections::disconnect(&server.server_id).await;
}
+93
View File
@@ -0,0 +1,93 @@
//! End-to-end test for the MCP setup-agent flow.
//!
//! Exercises the ref machinery + install_and_connect path without going
//! through a real upstream registry — the test inserts an
//! `InstalledServer` row directly to stand in for what
//! `install_and_connect` would have synthesised from
//! `registry::registry_get`. The transport itself is the same
//! `test-mcp-stub` binary used by `mcp_registry_e2e.rs`.
use std::collections::HashMap;
use std::time::Duration;
use openhuman_core::openhuman::config::Config;
use openhuman_core::openhuman::mcp_registry::setup::{self, SecretRef};
#[tokio::test]
async fn request_secret_blocks_until_submit_then_resolves() {
// Caller mints + awaits in one task, fulfiller submits in another.
// The exact API the setup_ops::request_secret handler uses.
let (r, rx) = setup::mint_request("API_KEY").await;
let r_for_submit = r.clone();
let submit_task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
let submitted = setup::fulfill(&r_for_submit, "shh-secret".to_string()).await;
assert!(submitted, "fulfill returns true on first submit");
});
// The await side: must not return before fulfill is called.
setup::await_fulfillment(&r, rx)
.await
.expect("await_fulfillment completes once submit lands");
submit_task.await.unwrap();
// Resolve maps {KEY: ref} -> {KEY: value} without exposing value to
// anywhere it shouldn't be.
let mut refs = HashMap::new();
refs.insert("API_KEY".to_string(), r.clone());
let resolved = setup::resolve_refs(&refs).await.expect("resolves");
assert_eq!(
resolved,
vec![("API_KEY".to_string(), "shh-secret".to_string())]
);
// The setup-agent contract: once install_and_connect persists the
// values, the refs are gone.
let _ = setup::consume_refs(&refs).await.expect("consumes");
assert!(
setup::resolve_refs(&refs).await.is_err(),
"post-consume resolve fails"
);
}
#[tokio::test]
async fn test_connection_against_stub_returns_tools() {
use openhuman_core::openhuman::mcp_client::McpStdioClient;
// Mirror what setup_ops::test_connection does end-to-end, minus the
// registry::registry_get step (we don't want to hit a real upstream
// from CI). The point of this test is the spawn + initialize +
// list_tools + teardown lifecycle the setup agent relies on.
let (r, _rx) = setup::mint_request("ECHO_TOKEN").await;
assert!(setup::fulfill(&r, "ignored-by-stub".to_string()).await);
let mut refs = HashMap::new();
refs.insert("ECHO_TOKEN".to_string(), r);
let env = setup::resolve_refs(&refs).await.expect("resolves");
assert_eq!(env.len(), 1);
let stub_path = env!("CARGO_BIN_EXE_test-mcp-stub");
let cfg = Config::default();
let identity = cfg.mcp_client.client_identity.clone();
let client = McpStdioClient::new(stub_path.to_string(), Vec::new(), env, None, identity);
client.initialize().await.expect("stub initialises");
let tools = client.list_tools().await.expect("stub lists tools");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "echo");
client.close_session().await.expect("stub closes");
}
#[tokio::test]
async fn invalid_ref_id_rejected_by_submit_secret() {
// The submit_secret handler parses the ref id via SecretRef::parse.
// Validate the parser independently here so an upstream regression
// doesn't silently re-admit unsafe inputs.
assert!(SecretRef::parse("secret://abc123").is_some());
assert!(SecretRef::parse("abc123").is_some());
assert!(SecretRef::parse("secret://not-hex!!").is_none());
assert!(SecretRef::parse("").is_none());
assert!(SecretRef::parse("../../etc/passwd").is_none());
}