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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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: 'جار تحميل إعدادات النموذج...',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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: 'モデル設定を読み込み中...',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '正在載入模型設定...',
|
||||
|
||||
@@ -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: '正在加载模型配置...',
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user