feat(desktop): hermes:// deep link to install MCP servers with explicit confirmation

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.
This commit is contained in:
Teknium
2026-08-16 01:59:25 -07:00
parent 3e0bf6dcaa
commit 56eafcff3c
14 changed files with 589 additions and 9 deletions
+5 -2
View File
@@ -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'
@@ -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
}
@@ -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<string, Record<string, unknown>>
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 | string[]>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(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 (
<Dialog onOpenChange={value => !value && close()} open>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{m.deepLinkTitle}</DialogTitle>
<DialogDescription>{m.deepLinkDescription}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
{request.transport === 'stdio' && (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{m.deepLinkStdioWarning}</span>
</div>
)}
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
{m.name}
<Input onChange={event => setName(event.target.value)} value={name} />
</label>
{!nameValid && <p className="text-xs text-destructive">{m.deepLinkNameInvalid}</p>}
{nameValid && nameConflict && <p className="text-xs text-destructive">{m.deepLinkNameConflict(trimmedName)}</p>}
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
{m.serverJson}
<pre className="max-h-64 overflow-auto rounded-md border border-border bg-muted/40 p-2 font-mono text-xs whitespace-pre-wrap break-all text-foreground">
{JSON.stringify(request.config, null, 2)}
</pre>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<DialogFooter>
<Button disabled={saving} onClick={close} type="button" variant="ghost">
{t.common.cancel}
</Button>
<Button
disabled={saving || !nameValid || nameConflict || checkingConflicts}
onClick={() => void confirm()}
variant={request.transport === 'stdio' ? 'destructive' : 'default'}
>
{saving ? t.common.saving : m.deepLinkConfirm}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+2
View File
@@ -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 }) {
<PetGenerateOverlay />
<SessionSwitcher />
<FileActionDialogs />
<McpInstallDeepLinkDialog />
<RemoteFolderPicker />
<FindBar />
+13 -1
View File
@@ -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: 'جار تحميل إعدادات النموذج...',
+15 -1
View File
@@ -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...',
+15 -1
View File
@@ -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: 'モデル設定を読み込み中...',
+12
View File
@@ -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
+13 -1
View File
@@ -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: '正在載入模型設定...',
+13 -1
View File
@@ -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: '正在加载模型配置...',
+97
View File
@@ -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'
})
})
})
+148
View File
@@ -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<string, unknown>
/** 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<McpDeepLinkErrorCode, string> = {
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<string, string | undefined>): 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<string, unknown>
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' }
}
@@ -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<McpInstallRequest | null>(null)
/** Validate a deep link's params into a pending install, or toast a rejection. */
export function requestMcpInstallFromDeepLink(params: Record<string, string | undefined>): 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)
}
@@ -335,3 +335,24 @@ Behavior:
- Tokens are persisted to `~/.hermes/mcp-tokens/<server>.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.