diff --git a/Cargo.toml b/Cargo.toml
index df843a7e2..06409c20f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"]
diff --git a/app/src/App.tsx b/app/src/App.tsx
index ddef2f4d4..f7d0db50e 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -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 && }
{!onMobile && }
{!onMobile && }
+
diff --git a/app/src/components/mcp-setup/SecretPromptDialog.test.tsx b/app/src/components/mcp-setup/SecretPromptDialog.test.tsx
new file mode 100644
index 000000000..00dcd0876
--- /dev/null
+++ b/app/src/components/mcp-setup/SecretPromptDialog.test.tsx
@@ -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();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('renders the prompt + key name when an event arrives', async () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ });
+});
diff --git a/app/src/components/mcp-setup/SecretPromptDialog.tsx b/app/src/components/mcp-setup/SecretPromptDialog.tsx
new file mode 100644
index 000000000..b8594fe0e
--- /dev/null
+++ b/app/src/components/mcp-setup/SecretPromptDialog.tsx
@@ -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 `` 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://` 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(null);
+ const [value, setValue] = useState('');
+ const [reveal, setReveal] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(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 (
+
+
e.stopPropagation()}>
+
+
+
+ );
+}
+
+export default SecretPromptDialog;
diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts
index 9f2b88ad1..2025fa576 100644
--- a/app/src/lib/i18n/chunks/ar-1.ts
+++ b/app/src/lib/i18n/chunks/ar-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts
index bac832920..3a4821df3 100644
--- a/app/src/lib/i18n/chunks/bn-1.ts
+++ b/app/src/lib/i18n/chunks/bn-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts
index 002d767d9..a1e24124c 100644
--- a/app/src/lib/i18n/chunks/de-1.ts
+++ b/app/src/lib/i18n/chunks/de-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts
index 5488c3d9a..440f6f08e 100644
--- a/app/src/lib/i18n/chunks/en-1.ts
+++ b/app/src/lib/i18n/chunks/en-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts
index 40646f082..7e0301ff1 100644
--- a/app/src/lib/i18n/chunks/es-1.ts
+++ b/app/src/lib/i18n/chunks/es-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts
index e3528744a..4647b030d 100644
--- a/app/src/lib/i18n/chunks/fr-1.ts
+++ b/app/src/lib/i18n/chunks/fr-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts
index 257d663d6..70d7a5143 100644
--- a/app/src/lib/i18n/chunks/hi-1.ts
+++ b/app/src/lib/i18n/chunks/hi-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts
index 183588787..b093a49d5 100644
--- a/app/src/lib/i18n/chunks/id-1.ts
+++ b/app/src/lib/i18n/chunks/id-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts
index 0d11bcfc0..48b0071da 100644
--- a/app/src/lib/i18n/chunks/it-1.ts
+++ b/app/src/lib/i18n/chunks/it-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts
index 910b1f04a..cd35fde5e 100644
--- a/app/src/lib/i18n/chunks/ko-1.ts
+++ b/app/src/lib/i18n/chunks/ko-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts
index 189bc5906..9de3752fa 100644
--- a/app/src/lib/i18n/chunks/pt-1.ts
+++ b/app/src/lib/i18n/chunks/pt-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts
index 15447d2f3..a3e3b1acc 100644
--- a/app/src/lib/i18n/chunks/ru-1.ts
+++ b/app/src/lib/i18n/chunks/ru-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts
index b35110823..03469f69f 100644
--- a/app/src/lib/i18n/chunks/zh-CN-1.ts
+++ b/app/src/lib/i18n/chunks/zh-CN-1.ts
@@ -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.',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index ede7298b3..e759f262e 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -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.',
diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts
index 361435948..758ef9bce 100644
--- a/app/src/services/socketService.ts
+++ b/app/src/services/socketService.ts
@@ -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 | 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 | null;
if (!obj || typeof obj !== 'object') return;
diff --git a/docs/MCP_SETUP_AGENT.md b/docs/MCP_SETUP_AGENT.md
new file mode 100644
index 000000000..eb454a44d
--- /dev/null
+++ b/docs/MCP_SETUP_AGENT.md
@@ -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://" }` | 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
+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>>`.
+- 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.
diff --git a/src/bin/test_mcp_stub.rs b/src/bin/test_mcp_stub.rs
new file mode 100644
index 000000000..d42f8b755
--- /dev/null
+++ b/src/bin/test_mcp_stub.rs
@@ -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");
+}
diff --git a/src/core/all.rs b/src/core/all.rs
index d87a3792f..e6cbebf68 100644
--- a/src/core/all.rs
+++ b/src/core/all.rs
@@ -115,7 +115,7 @@ fn build_registered_controllers() -> Vec {
// 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 {
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."),
diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs
index 6dd56348e..ca8d78ce6 100644
--- a/src/core/event_bus/events.rs
+++ b/src/core/event_bus/events.rs
@@ -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",
}
}
}
diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs
index cb1013bbe..6834bb1de 100644
--- a/src/core/jsonrpc.rs
+++ b/src/core/jsonrpc.rs
@@ -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());
diff --git a/src/core/socketio.rs b/src/core/socketio.rs
index 258c4628d..fe6e899c5 100644
--- a/src/core/socketio.rs
+++ b/src/core/socketio.rs
@@ -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();
diff --git a/src/openhuman/agent/agents/loader.rs b/src/openhuman/agent/agents/loader.rs
index a05ea446d..2bb7e5785 100644
--- a/src/openhuman/agent/agents/loader.rs
+++ b/src/openhuman/agent/agents/loader.rs
@@ -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`].
diff --git a/src/openhuman/agent/agents/mcp_setup/agent.toml b/src/openhuman/agent/agents/mcp_setup/agent.toml
new file mode 100644
index 000000000..2c568fd36
--- /dev/null
+++ b/src/openhuman/agent/agents/mcp_setup/agent.toml
@@ -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",
+]
diff --git a/src/openhuman/agent/agents/mcp_setup/mod.rs b/src/openhuman/agent/agents/mcp_setup/mod.rs
new file mode 100644
index 000000000..8bf84783c
--- /dev/null
+++ b/src/openhuman/agent/agents/mcp_setup/mod.rs
@@ -0,0 +1 @@
+pub mod prompt;
diff --git a/src/openhuman/agent/agents/mcp_setup/prompt.md b/src/openhuman/agent/agents/mcp_setup/prompt.md
new file mode 100644
index 000000000..a551bb947
--- /dev/null
+++ b/src/openhuman/agent/agents/mcp_setup/prompt.md
@@ -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 2–3 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.
diff --git a/src/openhuman/agent/agents/mcp_setup/prompt.rs b/src/openhuman/agent/agents/mcp_setup/prompt.rs
new file mode 100644
index 000000000..9017c9d82
--- /dev/null
+++ b/src/openhuman/agent/agents/mcp_setup/prompt.rs
@@ -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 {
+ 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> = 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}`");
+ }
+ }
+}
diff --git a/src/openhuman/agent/agents/mod.rs b/src/openhuman/agent/agents/mod.rs
index 5a64988aa..fc02b16a3 100644
--- a/src/openhuman/agent/agents/mod.rs
+++ b/src/openhuman/agent/agents/mod.rs
@@ -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;
diff --git a/src/openhuman/mcp_client/mod.rs b/src/openhuman/mcp_client/mod.rs
index 33db8dd4c..fde76b89e 100644
--- a/src/openhuman/mcp_client/mod.rs
+++ b/src/openhuman/mcp_client/mod.rs
@@ -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;
diff --git a/src/openhuman/mcp_clients/client/mod.rs b/src/openhuman/mcp_clients/client/mod.rs
deleted file mode 100644
index 4ab114f9d..000000000
--- a/src/openhuman/mcp_clients/client/mod.rs
+++ /dev/null
@@ -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,
- counter: RequestIdCounter,
- /// Cached tool list after `initialize`.
- cached_tools: Mutex>,
-}
-
-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,
- ) -> anyhow::Result> {
- tracing::debug!(
- "[mcp-client] spawn_and_init server_id={} command={} args={:?} env_keys={:?}",
- server_id,
- command,
- args,
- env.keys().collect::>()
- );
-
- 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 {
- self.cached_tools.lock().await.clone()
- }
-
- /// Return the last stderr line for error reporting.
- pub async fn last_error(&self) -> Option {
- self.process.lock().await.reader.last_stderr().await
- }
-}
-
-#[async_trait]
-impl McpTransport for McpStdioClient {
- async fn initialize(&self) -> Result {
- 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(¬if).await;
- }
-
- result
- }
-
- async fn list_tools(&self) -> Result, 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 {
- 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(¬if).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,
- /// Canned result for `call_tool`. If `Err`, the call returns that error.
- pub call_result: Result,
-}
-
-impl FakeMcpTransport {
- pub fn new(tools: Vec, call_result: Result) -> Arc {
- Arc::new(Self { tools, call_result })
- }
-
- pub fn empty() -> Arc {
- Self::new(Vec::new(), Ok(Value::Null))
- }
-}
-
-#[async_trait]
-impl McpTransport for FakeMcpTransport {
- async fn initialize(&self) -> Result {
- Ok(json!({
- "protocolVersion": transport::MCP_PROTOCOL_VERSION,
- "capabilities": {}
- }))
- }
-
- async fn list_tools(&self) -> Result, String> {
- Ok(self.tools.clone())
- }
-
- async fn call_tool(&self, _tool_name: &str, _arguments: Value) -> Result {
- 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;
- }
-}
diff --git a/src/openhuman/mcp_clients/client/protocol.rs b/src/openhuman/mcp_clients/client/protocol.rs
deleted file mode 100644
index c07edbb23..000000000
--- a/src/openhuman/mcp_clients/client/protocol.rs
+++ /dev/null
@@ -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;
-
- /// Send a `tools/list` request and return the parsed tool list.
- async fn list_tools(&self) -> Result, String>;
-
- /// Send a `tools/call` request.
- async fn call_tool(&self, tool_name: &str, arguments: Value) -> Result;
-
- /// Gracefully shut down (send `notifications/cancelled` or just close).
- async fn shutdown(&self);
-}
-
-// ── Shared request-counter helper ────────────────────────────────────────────
-
-pub struct RequestIdCounter(Arc>);
-
-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