From 56eafcff3cf91c10592dc690fa60ae51acf29a7f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:59:25 -0700 Subject: [PATCH] feat(desktop): hermes:// deep link to install MCP servers with explicit confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds hermes://mcp/install?name=NAME&config=B64 (base64url or standard base64 JSON), mirroring Cursor's mcp/install deep link, so vendors and docs can offer an "Add to Hermes" button. - Electron: the existing generic hermes:// handler already forwards {kind, name, params}; only its comment is updated (no new handler). - Renderer: use-desktop-integrations routes kind=mcp/name=install into a pending-install store; a new confirmation dialog shows the server name and the FULL pretty-printed config (attacker-controllable input), with a prominent caution for stdio command entries. Nothing is written until the user confirms; existing names require a rename or cancel. On confirm the server is merged over a fresh fetch of the current map via saveMcpServers, then navigation lands on /skills?tab=mcp&server=… so useDeepLinkHighlight focuses the new row. - Validation: name ^[A-Za-z0-9._-]{1,64}$; config must decode to an object with a string http(s) `url` or a string `command` (never both); payloads over 32KB rejected; failures surface as a toast. - Pure parser in src/lib/mcp-deeplink.ts with unit tests (url shape, command shape, bad base64, non-object, javascript: URL, oversized). - i18n keys in types + en/zh/zh-hant/ja/ar. - Docs: "Add to Hermes link" section in the MCP config reference. --- apps/desktop/electron/main.ts | 7 +- .../contrib/hooks/use-desktop-integrations.ts | 17 +- .../contrib/mcp-install-deeplink-dialog.tsx | 189 ++++++++++++++++++ apps/desktop/src/app/contrib/wiring.tsx | 2 + apps/desktop/src/i18n/ar.ts | 14 +- apps/desktop/src/i18n/en.ts | 16 +- apps/desktop/src/i18n/ja.ts | 16 +- apps/desktop/src/i18n/types.ts | 12 ++ apps/desktop/src/i18n/zh-hant.ts | 14 +- apps/desktop/src/i18n/zh.ts | 14 +- apps/desktop/src/lib/mcp-deeplink.test.ts | 97 +++++++++ apps/desktop/src/lib/mcp-deeplink.ts | 148 ++++++++++++++ .../desktop/src/store/mcp-deeplink-install.ts | 31 +++ .../docs/reference/mcp-config-reference.md | 21 ++ 14 files changed, 589 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx create mode 100644 apps/desktop/src/lib/mcp-deeplink.test.ts create mode 100644 apps/desktop/src/lib/mcp-deeplink.ts create mode 100644 apps/desktop/src/store/mcp-deeplink-install.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 8728b180ab..21f690ccd0 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -14080,9 +14080,12 @@ ipcMain.handle('hermes:vscode-theme:fetch', async (_event, id) => fetchMarketpla ipcMain.handle('hermes:vscode-theme:search', async (_event, query) => searchMarketplaceThemes(String(query || ''), 20)) // --------------------------------------------------------------------------- -// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00). +// hermes:// deep links (e.g. hermes://blueprint/morning-brief?time=08:00, or +// hermes://mcp/install?name=NAME&config=B64 — the vendor "Add to Hermes" +// button). Parsing is generic ({kind, name, params}); the renderer routes per +// kind and anything install-shaped requires explicit user confirmation there. // A docs/dashboard "Send to App" button opens this URL; we route it into the -// running app's chat composer. Three delivery paths: macOS 'open-url', +// running app. Three delivery paths: macOS 'open-url', // Win/Linux running-app 'second-instance' (argv), Win/Linux cold-start argv. // --------------------------------------------------------------------------- const HERMES_PROTOCOL = 'hermes' diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts index 9793fab277..54a0f287df 100644 --- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts +++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts @@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react' import { closeActiveTab } from '@/app/chat/close-tab' import { openSession } from '@/app/open-session' import { storedSessionIdForNotification } from '@/lib/session-ids' +import { requestMcpInstallFromDeepLink } from '@/store/mcp-deeplink-install' import { respondToApprovalAction } from '@/store/native-notifications' import { openFolderAsProject } from '@/store/projects' import { @@ -187,10 +188,22 @@ export function useDesktopIntegrations({ return () => unsubscribe?.() }, []) - // hermes:// deep links -> a reviewable /blueprint command in the composer. + // hermes:// deep links -> a reviewable /blueprint command in the composer, + // or (hermes://mcp/install) a pending MCP install awaiting explicit + // confirmation in McpInstallDeepLinkDialog. Never auto-installs. useEffect(() => { const unsubscribe = window.hermesDesktop?.onDeepLink?.(payload => { - if (!payload || payload.kind !== 'blueprint' || !payload.name) { + if (!payload) { + return + } + + if (payload.kind === 'mcp' && payload.name === 'install') { + requestMcpInstallFromDeepLink(payload.params || {}) + + return + } + + if (payload.kind !== 'blueprint' || !payload.name) { return } diff --git a/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx b/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx new file mode 100644 index 0000000000..b7c3e2389d --- /dev/null +++ b/apps/desktop/src/app/contrib/mcp-install-deeplink-dialog.tsx @@ -0,0 +1,189 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { getHermesConfigRecord, saveMcpServers } from '@/hermes' +import { useI18n } from '@/i18n' +import { AlertTriangle } from '@/lib/icons' +import { MCP_DEEPLINK_NAME_RE } from '@/lib/mcp-deeplink' +import { $mcpInstallRequest } from '@/store/mcp-deeplink-install' +import { notify, readableError } from '@/store/notifications' + +import { setHermesConfigCache } from '../hooks/use-config-record' + +type McpServers = Record> + +const getServers = (config: { mcp_servers?: unknown } | null): McpServers => { + const raw = config?.mcp_servers + + return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {} +} + +/** + * Explicit-confirm gate for `hermes://mcp/install` deep links. The payload is + * arbitrary attacker-controllable input (any web page can open the link), so + * this dialog shows the server name and the FULL pretty-printed config — + * exactly what would be written — and nothing touches config until the user + * confirms. stdio (`command`) entries carry an extra caution banner because + * confirming lets Hermes spawn that local process. An existing server name is + * never silently overwritten: confirm stays blocked until the user picks a + * fresh name or cancels. + */ +export function McpInstallDeepLinkDialog() { + const { t } = useI18n() + const m = t.settings.mcp + const navigate = useNavigate() + const request = useStore($mcpInstallRequest) + + const [name, setName] = useState('') + const [existingNames, setExistingNames] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + // (Re)arm per request: seed the editable name and fetch the current server + // map so a same-name conflict is visible before the user confirms. + useEffect(() => { + if (!request) { + return + } + + setName(request.name) + setExistingNames(null) + setSaving(false) + setError(null) + + let cancelled = false + + getHermesConfigRecord() + .then(config => { + if (!cancelled) { + setExistingNames(Object.keys(getServers(config))) + } + }) + .catch(() => { + // Conflict preflight failed (offline backend?) — confirm still re-fetches + // and merges, so leave the dialog usable rather than wedging it. + if (!cancelled) { + setExistingNames([]) + } + }) + + return () => { + cancelled = true + } + }, [request]) + + if (!request) { + return null + } + + const trimmedName = name.trim() + const nameValid = MCP_DEEPLINK_NAME_RE.test(trimmedName) + const nameConflict = existingNames?.includes(trimmedName) ?? false + const checkingConflicts = existingNames === null + + const close = () => { + if (!saving) { + $mcpInstallRequest.set(null) + } + } + + const confirm = async () => { + if (saving || !nameValid || nameConflict || checkingConflicts) { + return + } + + setSaving(true) + setError(null) + + try { + // Merge over the FRESHEST server map — saveMcpServers replaces the whole + // `mcp_servers` document, so saving over a stale snapshot would drop + // servers added elsewhere since the dialog opened. + const current = getServers(await getHermesConfigRecord()) + + if (Object.prototype.hasOwnProperty.call(current, trimmedName)) { + setExistingNames(Object.keys(current)) + setError(m.deepLinkNameConflict(trimmedName)) + + return + } + + const nextServers = { ...current, [trimmedName]: request.config } + await saveMcpServers(nextServers) + setHermesConfigCache(previous => (previous ? { ...previous, mcp_servers: nextServers } : previous)) + notify({ kind: 'success', title: m.savedTitle, message: m.savedMessage(trimmedName) }) + $mcpInstallRequest.set(null) + navigate(`/skills?tab=mcp&server=${encodeURIComponent(trimmedName)}`) + } catch (err) { + setError(readableError(err, m.saveFailed).message) + } finally { + setSaving(false) + } + } + + return ( + !value && close()} open> + + + {m.deepLinkTitle} + {m.deepLinkDescription} + + +
+ {request.transport === 'stdio' && ( +
+ + {m.deepLinkStdioWarning} +
+ )} + + + + {!nameValid &&

{m.deepLinkNameInvalid}

} + {nameValid && nameConflict &&

{m.deepLinkNameConflict(trimmedName)}

} + +
+ {m.serverJson} +
+              {JSON.stringify(request.config, null, 2)}
+            
+
+ + {error && ( +
+ + {error} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 2ad4456c12..4051c9e820 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -133,6 +133,7 @@ import { useDesktopIntegrations } from './hooks/use-desktop-integrations' import { usePetBridge } from './hooks/use-pet-bridge' import { useQuickEntryBridge } from './hooks/use-quick-entry-bridge' import { useSessionTileDelegate } from './hooks/use-session-tile-delegate' +import { McpInstallDeepLinkDialog } from './mcp-install-deeplink-dialog' import { $restartPreviewServer, useTitlebarToolContributions } from './panes' import { ChatRoutesSurface, SidebarSurface, StatusbarSurface, TerminalSurface } from './surfaces' import type { WiringActions, WiringApi } from './types' @@ -1077,6 +1078,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { + diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 32c39263b9..8bcc30d032 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -761,7 +761,19 @@ export const ar = defineLocale({ name: 'الاسم', serverJson: 'JSON الخادم', remove: 'إزالة', - saveServer: 'حفظ الخادم' + saveServer: 'حفظ الخادم', + deepLinkTitle: 'إضافة خادم MCP؟', + deepLinkDescription: 'طلب رابط إضافة خادم MCP هذا إلى Hermes. راجع الإعدادات الكاملة أدناه — فهي قادمة من الرابط وليست من Hermes.', + deepLinkStdioWarning: 'سيشغّل هذا الخادم عملية محلية على جهازك بالأمر الموضح أدناه. لا تتابع إلا إذا كنت تثق بمصدره.', + deepLinkConfirm: 'إضافة الخادم', + deepLinkNameInvalid: 'الأسماء من 1-64 حرفا أو رقما أو نقطة أو شرطة أو شرطة سفلية.', + deepLinkNameConflict: name => `يوجد خادم باسم ${name} بالفعل — اختر اسما مختلفا أو ألغِ العملية.`, + deepLinkErrorTitle: 'رُفض رابط تثبيت MCP', + deepLinkErrorName: 'اسم الخادم في الرابط مفقود أو غير صالح.', + deepLinkErrorConfig: 'إعدادات الرابط ليست JSON صالحا مرمّزا بـ base64.', + deepLinkErrorShape: 'يجب أن تكون الإعدادات كائن JSON يحتوي على حقل `url` أو `command` نصي.', + deepLinkErrorUrl: 'يسمح فقط بعناوين http:// و https:// للخادم.', + deepLinkErrorTooLarge: 'حجم الإعدادات يتجاوز الحد الأقصى 32KB.' }, model: { loading: 'جار تحميل إعدادات النموذج...', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 0931d0c48f..313972eccb 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -900,7 +900,21 @@ export const en: Translations = { unsavedConnect: 'Unsaved — save mcp.json to connect.', enableTool: tool => `Enable ${tool}`, disableTool: tool => `Disable ${tool}`, - noOutput: 'No output yet.' + noOutput: 'No output yet.', + deepLinkTitle: 'Add MCP server?', + deepLinkDescription: + 'A link asked to add this MCP server to Hermes. Review the exact configuration below — it comes from the link, not from Hermes.', + deepLinkStdioWarning: + 'This server runs a local process on your machine with the command shown below. Only continue if you trust its source.', + deepLinkConfirm: 'Add server', + deepLinkNameInvalid: 'Names use 1-64 letters, digits, dots, dashes, or underscores.', + deepLinkNameConflict: name => `A server named ${name} already exists — choose a different name or cancel.`, + deepLinkErrorTitle: 'MCP install link rejected', + deepLinkErrorName: 'The link\u2019s server name is missing or invalid.', + deepLinkErrorConfig: 'The link\u2019s config is not valid base64-encoded JSON.', + deepLinkErrorShape: 'The config must be a JSON object with a string `url` or `command` field.', + deepLinkErrorUrl: 'Only http:// and https:// server URLs are allowed.', + deepLinkErrorTooLarge: 'The config payload exceeds the 32KB limit.' }, model: { loading: 'Loading model configuration...', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index e174f64998..639f2c78bd 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -879,7 +879,21 @@ export const ja = defineLocale({ unsavedConnect: '未保存 — 接続するには mcp.json を保存してください。', enableTool: tool => `${tool} を有効化`, disableTool: tool => `${tool} を無効化`, - noOutput: 'まだ出力がありません。' + noOutput: 'まだ出力がありません。', + deepLinkTitle: 'MCP サーバーを追加しますか?', + deepLinkDescription: + 'リンクがこの MCP サーバーを Hermes に追加するよう要求しました。下の設定はリンク側から来たものです。内容を必ず確認してください。', + deepLinkStdioWarning: + 'このサーバーは下記のコマンドでローカルプロセスを実行します。提供元を信頼できる場合のみ続行してください。', + deepLinkConfirm: 'サーバーを追加', + deepLinkNameInvalid: '名前は 1〜64 文字の英数字、ドット、ハイフン、アンダースコアです。', + deepLinkNameConflict: name => `${name} という名前のサーバーは既に存在します。別の名前にするかキャンセルしてください。`, + deepLinkErrorTitle: 'MCP インストールリンクを拒否しました', + deepLinkErrorName: 'リンクのサーバー名が欠落しているか無効です。', + deepLinkErrorConfig: 'リンクの設定が有効な base64 エンコード JSON ではありません。', + deepLinkErrorShape: '設定は文字列の `url` または `command` フィールドを持つ JSON オブジェクトである必要があります。', + deepLinkErrorUrl: 'サーバー URL は http:// と https:// のみ許可されます。', + deepLinkErrorTooLarge: '設定ペイロードが 32KB の上限を超えています。' }, model: { loading: 'モデル設定を読み込み中...', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index bd7878af0a..9d75817366 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -774,6 +774,18 @@ export interface Translations { enableTool: (tool: string) => string disableTool: (tool: string) => string noOutput: string + deepLinkTitle: string + deepLinkDescription: string + deepLinkStdioWarning: string + deepLinkConfirm: string + deepLinkNameInvalid: string + deepLinkNameConflict: (name: string) => string + deepLinkErrorTitle: string + deepLinkErrorName: string + deepLinkErrorConfig: string + deepLinkErrorShape: string + deepLinkErrorUrl: string + deepLinkErrorTooLarge: string } model: { loading: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index e8f7204433..cde02a5e9a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -853,7 +853,19 @@ export const zhHant = defineLocale({ unsavedConnect: '未儲存 — 儲存 mcp.json 以連線。', enableTool: tool => `啟用 ${tool}`, disableTool: tool => `停用 ${tool}`, - noOutput: '尚無輸出。' + noOutput: '尚無輸出。', + deepLinkTitle: '新增 MCP 伺服器?', + deepLinkDescription: '一個連結要求將此 MCP 伺服器加入 Hermes。請檢查下方的完整設定——它來自該連結,而非 Hermes。', + deepLinkStdioWarning: '此伺服器會使用下方所示指令在你的電腦上執行本機程序。僅在信任其來源時繼續。', + deepLinkConfirm: '新增伺服器', + deepLinkNameInvalid: '名稱須為 1-64 個字母、數字、點、連字號或底線。', + deepLinkNameConflict: name => `已存在名為 ${name} 的伺服器——請改用其他名稱或取消。`, + deepLinkErrorTitle: '已拒絕 MCP 安裝連結', + deepLinkErrorName: '連結中的伺服器名稱缺失或無效。', + deepLinkErrorConfig: '連結中的設定不是有效的 base64 編碼 JSON。', + deepLinkErrorShape: '設定必須是包含字串 `url` 或 `command` 欄位的 JSON 物件。', + deepLinkErrorUrl: '僅允許 http:// 和 https:// 伺服器網址。', + deepLinkErrorTooLarge: '設定內容超過 32KB 上限。' }, model: { loading: '正在載入模型設定...', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 55c35c01d2..04ef547fcf 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1099,7 +1099,19 @@ export const zh: Translations = { unsavedConnect: '未保存 — 保存 mcp.json 以连接。', enableTool: tool => `启用 ${tool}`, disableTool: tool => `禁用 ${tool}`, - noOutput: '暂无输出。' + noOutput: '暂无输出。', + deepLinkTitle: '添加 MCP 服务器?', + deepLinkDescription: '一个链接请求将此 MCP 服务器添加到 Hermes。请检查下方的完整配置——它来自该链接,而非 Hermes。', + deepLinkStdioWarning: '此服务器会使用下方所示命令在你的电脑上运行本地进程。仅在信任其来源时继续。', + deepLinkConfirm: '添加服务器', + deepLinkNameInvalid: '名称须为 1-64 个字母、数字、点、连字符或下划线。', + deepLinkNameConflict: name => `已存在名为 ${name} 的服务器——请改用其他名称或取消。`, + deepLinkErrorTitle: 'MCP 安装链接已拒绝', + deepLinkErrorName: '链接中的服务器名称缺失或无效。', + deepLinkErrorConfig: '链接中的配置不是有效的 base64 编码 JSON。', + deepLinkErrorShape: '配置必须是包含字符串 `url` 或 `command` 字段的 JSON 对象。', + deepLinkErrorUrl: '仅允许 http:// 和 https:// 服务器地址。', + deepLinkErrorTooLarge: '配置负载超过 32KB 上限。' }, model: { loading: '正在加载模型配置...', diff --git a/apps/desktop/src/lib/mcp-deeplink.test.ts b/apps/desktop/src/lib/mcp-deeplink.test.ts new file mode 100644 index 0000000000..5e99e21eba --- /dev/null +++ b/apps/desktop/src/lib/mcp-deeplink.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import { MCP_DEEPLINK_MAX_CONFIG_BYTES, parseMcpInstallDeepLink } from './mcp-deeplink' + +const b64url = (value: unknown) => + btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + +describe('parseMcpInstallDeepLink', () => { + it('accepts a url-shaped config (base64url)', () => { + const result = parseMcpInstallDeepLink({ + name: 'context7', + config: b64url({ url: 'https://mcp.context7.com/mcp', headers: { Authorization: 'Bearer x' } }) + }) + + expect(result).toEqual({ + ok: true, + request: { + name: 'context7', + transport: 'http', + config: { url: 'https://mcp.context7.com/mcp', headers: { Authorization: 'Bearer x' } } + } + }) + }) + + it('accepts a command-shaped config (standard base64 with padding)', () => { + const config = { command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'] } + const result = parseMcpInstallDeepLink({ name: 'fs.local-1', config: btoa(JSON.stringify(config)) }) + + expect(result).toEqual({ ok: true, request: { name: 'fs.local-1', transport: 'stdio', config } }) + }) + + it('rejects names outside ^[A-Za-z0-9._-]{1,64}$', () => { + const config = b64url({ url: 'https://example.com/mcp' }) + + for (const name of ['', 'has space', 'näme', 'a/b', 'a'.repeat(65)]) { + expect(parseMcpInstallDeepLink({ name, config })).toEqual({ ok: false, error: 'invalid_name' }) + } + }) + + it('rejects a missing or undecodable config param', () => { + expect(parseMcpInstallDeepLink({ name: 'x' })).toEqual({ ok: false, error: 'missing_config' }) + expect(parseMcpInstallDeepLink({ name: 'x', config: '!!!not-base64!!!' })).toEqual({ + ok: false, + error: 'bad_encoding' + }) + // Valid base64, not JSON. + expect(parseMcpInstallDeepLink({ name: 'x', config: btoa('not json') })).toEqual({ + ok: false, + error: 'bad_encoding' + }) + }) + + it('rejects non-object configs', () => { + for (const value of ['a string', 42, null, true, ['array']]) { + expect(parseMcpInstallDeepLink({ name: 'x', config: b64url(value) })).toEqual({ + ok: false, + error: 'not_an_object' + }) + } + }) + + it('rejects configs with neither url nor command', () => { + expect(parseMcpInstallDeepLink({ name: 'x', config: b64url({ env: { A: '1' } }) })).toEqual({ + ok: false, + error: 'invalid_shape' + }) + // Non-string command. + expect(parseMcpInstallDeepLink({ name: 'x', config: b64url({ command: ['npx'] }) })).toEqual({ + ok: false, + error: 'invalid_shape' + }) + }) + + it('rejects non-http(s) url schemes', () => { + for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'ftp://x', 'not a url']) { + expect(parseMcpInstallDeepLink({ name: 'x', config: b64url({ url }) })).toEqual({ + ok: false, + error: 'invalid_url' + }) + } + }) + + it('rejects ambiguous configs carrying both url and command', () => { + expect( + parseMcpInstallDeepLink({ name: 'x', config: b64url({ url: 'https://example.com', command: 'rm' }) }) + ).toEqual({ ok: false, error: 'invalid_shape' }) + }) + + it('rejects oversized payloads', () => { + const big = { url: 'https://example.com/mcp', padding: 'x'.repeat(MCP_DEEPLINK_MAX_CONFIG_BYTES) } + + expect(parseMcpInstallDeepLink({ name: 'x', config: b64url(big) })).toEqual({ + ok: false, + error: 'payload_too_large' + }) + }) +}) diff --git a/apps/desktop/src/lib/mcp-deeplink.ts b/apps/desktop/src/lib/mcp-deeplink.ts new file mode 100644 index 0000000000..21e13a8545 --- /dev/null +++ b/apps/desktop/src/lib/mcp-deeplink.ts @@ -0,0 +1,148 @@ +/** + * Parser/validator for the `hermes://mcp/install?name=NAME&config=B64` deep + * link (the "Add to Hermes" button MCP vendors embed, mirroring Cursor's + * `cursor://anysphere.cursor-deeplink/mcp/install` scheme). `config` is + * base64url-encoded JSON of a single server config object; standard base64 is + * accepted too. + * + * Everything here is HOSTILE INPUT — any web page can open the link — so this + * module only classifies. It never installs: the caller must show the decoded + * config to the user and require an explicit confirmation before saving. + */ + +export const MCP_DEEPLINK_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/ + +/** Hard cap on the DECODED config JSON (bytes). */ +export const MCP_DEEPLINK_MAX_CONFIG_BYTES = 32 * 1024 + +// The base64 text is ~4/3 the decoded size; reject grossly oversized params +// before doing any decode work. +const MAX_ENCODED_LENGTH = Math.ceil((MCP_DEEPLINK_MAX_CONFIG_BYTES * 4) / 3) + 4 + +export type McpDeepLinkErrorCode = + | 'bad_encoding' + | 'invalid_name' + | 'invalid_shape' + | 'invalid_url' + | 'missing_config' + | 'not_an_object' + | 'payload_too_large' + +export interface McpInstallRequest { + name: string + config: Record + /** Inferred from the config shape: `url` -> http, `command` -> stdio. */ + transport: 'http' | 'stdio' +} + +export type McpDeepLinkParseResult = + | { ok: false; error: McpDeepLinkErrorCode } + | { ok: true; request: McpInstallRequest } + +/** i18n key (under `settings.mcp`) for each rejection, used by the toast. */ +export const MCP_DEEPLINK_ERROR_KEYS: Record = { + bad_encoding: 'deepLinkErrorConfig', + invalid_name: 'deepLinkErrorName', + invalid_shape: 'deepLinkErrorShape', + invalid_url: 'deepLinkErrorUrl', + missing_config: 'deepLinkErrorConfig', + not_an_object: 'deepLinkErrorConfig', + payload_too_large: 'deepLinkErrorTooLarge' +} + +/** base64url or standard base64 -> bytes, or null when not valid base64. */ +function decodeBase64(text: string): null | Uint8Array { + const normalized = text.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '') + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4) + + try { + const binary = atob(padded) + const bytes = new Uint8Array(binary.length) + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + + return bytes + } catch { + return null + } +} + +/** + * Classify the deep link's query params into an installable request or a + * rejection code. Pure and side-effect free; the confirmation UX is the + * caller's responsibility. + */ +export function parseMcpInstallDeepLink(params: Record): McpDeepLinkParseResult { + const name = params.name ?? '' + + if (!MCP_DEEPLINK_NAME_RE.test(name)) { + return { ok: false, error: 'invalid_name' } + } + + const encoded = params.config ?? '' + + if (!encoded) { + return { ok: false, error: 'missing_config' } + } + + if (encoded.length > MAX_ENCODED_LENGTH) { + return { ok: false, error: 'payload_too_large' } + } + + const bytes = decodeBase64(encoded) + + if (!bytes) { + return { ok: false, error: 'bad_encoding' } + } + + if (bytes.length > MCP_DEEPLINK_MAX_CONFIG_BYTES) { + return { ok: false, error: 'payload_too_large' } + } + + let parsed: unknown + + try { + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown + } catch { + return { ok: false, error: 'bad_encoding' } + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, error: 'not_an_object' } + } + + const config = parsed as Record + const hasUrl = typeof config.url === 'string' + const hasCommand = typeof config.command === 'string' && (config.command as string).trim().length > 0 + + // Exactly one transport: a config carrying BOTH `url` and `command` is + // ambiguous (which one runs depends on loader precedence) — reject it so the + // user can't be shown "just a URL" while a command rides along. + if (hasUrl && 'command' in config) { + return { ok: false, error: 'invalid_shape' } + } + + if (hasUrl) { + let target: URL + + try { + target = new URL(config.url as string) + } catch { + return { ok: false, error: 'invalid_url' } + } + + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + return { ok: false, error: 'invalid_url' } + } + + return { ok: true, request: { name, config, transport: 'http' } } + } + + if (hasCommand) { + return { ok: true, request: { name, config, transport: 'stdio' } } + } + + return { ok: false, error: 'invalid_shape' } +} diff --git a/apps/desktop/src/store/mcp-deeplink-install.ts b/apps/desktop/src/store/mcp-deeplink-install.ts new file mode 100644 index 0000000000..9f409e915a --- /dev/null +++ b/apps/desktop/src/store/mcp-deeplink-install.ts @@ -0,0 +1,31 @@ +import { atom } from 'nanostores' + +import { translateNow } from '@/i18n' +import { MCP_DEEPLINK_ERROR_KEYS, type McpInstallRequest, parseMcpInstallDeepLink } from '@/lib/mcp-deeplink' + +import { notify } from './notifications' + +/** + * Pending `hermes://mcp/install` request awaiting the user's explicit + * confirmation. Set by the deep-link listener, consumed by + * `McpInstallDeepLinkDialog`; null means no dialog. Nothing is written to + * config until the user confirms in the dialog. + */ +export const $mcpInstallRequest = atom(null) + +/** Validate a deep link's params into a pending install, or toast a rejection. */ +export function requestMcpInstallFromDeepLink(params: Record): void { + const result = parseMcpInstallDeepLink(params) + + if (!result.ok) { + notify({ + kind: 'error', + title: translateNow('settings.mcp.deepLinkErrorTitle'), + message: translateNow(`settings.mcp.${MCP_DEEPLINK_ERROR_KEYS[result.error]}`) + }) + + return + } + + $mcpInstallRequest.set(result.request) +} diff --git a/website/docs/reference/mcp-config-reference.md b/website/docs/reference/mcp-config-reference.md index 1a851ae013..a523740b91 100644 --- a/website/docs/reference/mcp-config-reference.md +++ b/website/docs/reference/mcp-config-reference.md @@ -335,3 +335,24 @@ Behavior: - Tokens are persisted to `~/.hermes/mcp-tokens/.json` and reused across sessions - Token refresh is automatic; re-authorization only happens when refresh fails - Only applies to HTTP/StreamableHTTP transport (`url`-based servers) + +## Add to Hermes link + +MCP vendors and docs can offer a one-click **"Add to Hermes"** button that opens the Hermes desktop app with a pre-filled server config, mirroring Cursor's `cursor://anysphere.cursor-deeplink/mcp/install` scheme: + +```text +hermes://mcp/install?name=NAME&config=BASE64 +``` + +- `name` — the server name. Must match `^[A-Za-z0-9._-]{1,64}$`. +- `config` — the server config object as **base64url-encoded JSON** (standard base64 is also accepted). The decoded JSON must be an object with either a string `url` field (`http://`/`https://` only) or a string `command` field, and may carry any of the server keys documented above. Payloads over 32KB are rejected. + +Example (JavaScript): + +```js +const config = { url: 'https://mcp.example.com/mcp' } +const link = `hermes://mcp/install?name=example&config=${btoa(JSON.stringify(config)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}` +``` + +Opening the link never installs anything by itself: the desktop app shows a confirmation dialog with the server name and the full pretty-printed config (with an extra caution for `command`-based servers, which run a local process), and the user must explicitly confirm. Existing server names are never overwritten — the user is asked to rename or cancel.