Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/desktop/src/app/skills/mcp-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
getActionStatus,
getLogs,
getMcpCatalog,
getMcpOAuthFlow,
type HermesGateway,
installMcpCatalogEntry,
type McpCatalogEntry,
Expand All @@ -38,6 +39,7 @@ import {
testMcpServer
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { completeMcpDesktopOAuth } from '@/lib/mcp-dashboard-oauth'
import { countEnabledTools, isToolEnabled, toggleToolInServer } from '@/lib/mcp-tool-filter'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
Expand Down Expand Up @@ -578,7 +580,14 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
setProbes(current => ({ ...current, [serverName]: 'probing' }))

try {
const result = await authMcpServer(serverName)
const flow = await completeMcpDesktopOAuth({
serverName,
start: authMcpServer,
status: getMcpOAuthFlow,
openExternal: url => window.hermesDesktop.openExternal(url)
})

const result: McpTestResult = { ok: true, tools: flow.tools ?? [] }

// Bail if the user switched profiles mid-flow — this result is profile A's.
if (profileEpoch.current !== epoch) {
Expand Down
25 changes: 20 additions & 5 deletions apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,15 @@ export interface McpTestResult {
resources?: number
}

export interface McpOAuthFlow {
flow_id: string
server_name: string
status: 'starting' | 'authorization_required' | 'approved' | 'error'
authorization_url: string | null
error: string | null
tools?: { name: string; description: string }[]
}

/** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes
* for real) — well past the 15s default fetch timeout. */
export function testMcpServer(name: string): Promise<McpTestResult> {
Expand All @@ -732,14 +741,20 @@ export function saveMcpServers(servers: Record<string, Record<string, unknown>>)
})
}

/** Run the OAuth flow for an HTTP server — opens the system browser and blocks
* until the user finishes (or gives up), hence the very generous timeout. */
export function authMcpServer(name: string): Promise<McpTestResult> {
return window.hermesDesktop.api<McpTestResult>({
/** Start an MCP OAuth flow and return the authorization URL. */
export function authMcpServer(name: string): Promise<McpOAuthFlow> {
return window.hermesDesktop.api<McpOAuthFlow>({
...profileScoped(),
path: `/api/mcp/servers/${encodeURIComponent(name)}/auth`,
method: 'POST',
timeoutMs: 300_000
timeoutMs: 60_000
})
}

export function getMcpOAuthFlow(flowId: string): Promise<McpOAuthFlow> {
return window.hermesDesktop.api<McpOAuthFlow>({
...profileScoped(),
path: `/api/mcp/oauth/flows/${encodeURIComponent(flowId)}`
})
}

Expand Down
59 changes: 59 additions & 0 deletions apps/desktop/src/lib/mcp-dashboard-oauth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'

import { completeMcpDesktopOAuth } from './mcp-dashboard-oauth'

describe('completeMcpDesktopOAuth', () => {
it('opens the returned authorization URL and polls through approval', async () => {
const openExternal = vi.fn().mockResolvedValue(undefined)

const status = vi
.fn()
.mockResolvedValueOnce({
flow_id: 'flow-1', server_name: 'reports', status: 'authorization_required',
authorization_url: 'https://idp.example/authorize', error: null
})
.mockResolvedValueOnce({
flow_id: 'flow-1', server_name: 'reports', status: 'approved',
authorization_url: 'https://idp.example/authorize', error: null,
tools: [{ name: 'list_reports', description: 'List reports' }]
})

const result = await completeMcpDesktopOAuth({
serverName: 'reports',
start: vi.fn().mockResolvedValue({
flow_id: 'flow-1', server_name: 'reports', status: 'authorization_required',
authorization_url: 'https://idp.example/authorize', error: null
}),
status,
openExternal,
sleep: async () => {}
})

expect(openExternal).toHaveBeenCalledWith('https://idp.example/authorize')
expect(result.status).toBe('approved')
})

it('retries a transient status failure', async () => {
const status = vi
.fn()
.mockRejectedValueOnce(new Error('temporary network failure'))
.mockResolvedValueOnce({
flow_id: 'flow-2', server_name: 'reports', status: 'approved',
authorization_url: 'https://idp.example/authorize', error: null, tools: []
})

const result = await completeMcpDesktopOAuth({
serverName: 'reports',
start: vi.fn().mockResolvedValue({
flow_id: 'flow-2', server_name: 'reports', status: 'authorization_required',
authorization_url: 'https://idp.example/authorize', error: null
}),
status,
openExternal: vi.fn().mockResolvedValue(undefined),
sleep: async () => {}
})

expect(result.status).toBe('approved')
expect(status).toHaveBeenCalledTimes(2)
})
})
72 changes: 72 additions & 0 deletions apps/desktop/src/lib/mcp-dashboard-oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
export interface McpOAuthFlow {
flow_id: string
server_name: string
status: 'starting' | 'authorization_required' | 'approved' | 'error'
authorization_url: string | null
error: string | null
tools?: Array<{ name: string; description: string }>
}

interface CompleteOptions {
serverName: string
start: (name: string) => Promise<McpOAuthFlow>
status: (flowId: string) => Promise<McpOAuthFlow>
openExternal: (url: string) => Promise<void>
sleep?: (milliseconds: number) => Promise<void>
maxPollFailures?: number
}

const defaultSleep = (milliseconds: number) =>
new Promise<void>(resolve => window.setTimeout(resolve, milliseconds))

export async function completeMcpDesktopOAuth({
serverName,
start,
status,
openExternal,
sleep = defaultSleep,
maxPollFailures = 3
}: CompleteOptions): Promise<McpOAuthFlow> {
const started = await start(serverName)

if (started.status === 'error') {
throw new Error(started.error || 'OAuth failed to start')
}

if (!started.authorization_url) {
throw new Error('OAuth server did not provide an authorization URL')
}

await openExternal(started.authorization_url)

let pollFailures = 0

for (;;) {
let current: McpOAuthFlow

try {
current = await status(started.flow_id)
pollFailures = 0
} catch (error) {
pollFailures += 1

if (pollFailures >= maxPollFailures) {
throw error
}

await sleep(1000)

continue
}

if (current.status === 'approved') {
return current
}

if (current.status === 'error') {
throw new Error(current.error || 'OAuth authorization failed')
}

await sleep(1000)
}
}
1 change: 1 addition & 0 deletions hermes_cli/dashboard_auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"/auth/logout",
"/login",
"/api/auth/providers",
"/api/mcp/oauth/callback/",
"/assets/",
"/favicon.ico",
"/ds-assets/",
Expand Down
13 changes: 8 additions & 5 deletions hermes_cli/mcp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,11 +264,14 @@ def _resolve_mcp_server_config(config: dict) -> dict:
"""
from tools.mcp_tool import _interpolate_env_vars

try:
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv()
except Exception: # pragma: no cover — defensive
pass
from agent.secret_scope import current_secret_scope

if current_secret_scope() is None:
try:
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv()
except Exception: # pragma: no cover — defensive
pass
return _interpolate_env_vars(config)


Expand Down
Loading
Loading