500 lines
15 KiB
TypeScript
500 lines
15 KiB
TypeScript
/**
|
|
* Inline per-profile MCP setup: the `mcp.servers.*` RPC wrapper, its
|
|
* feature-detect, and the button a capability row renders.
|
|
*
|
|
* Shared leaf: the advanced profile editor and the create dialog both render
|
|
* the button, so it lives below both.
|
|
*/
|
|
|
|
import { Button, host, Input, useI18n } from '@hermes/plugin-sdk'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
// -- inline MCP setup (per-profile), driven by the mcp.servers.* gateway RPCs --
|
|
// Feature-detected: if the gateway predates those RPCs the setup button hides
|
|
// and the row falls back to the "run hermes mcp / Settings" hint. profile is
|
|
// the target bot's profile name (its config is what we write).
|
|
|
|
/** Body of an `mcp.servers.*` reply. Some gateway builds wrap it in a second
|
|
* `result` envelope, which every call site below unwraps — hence the
|
|
* self-reference. */
|
|
interface McpServerPayload {
|
|
auth_url?: string
|
|
error?: string
|
|
error_message?: string
|
|
ok?: boolean
|
|
result?: McpServerPayload
|
|
session_id?: string
|
|
status?: string
|
|
verification_url?: string
|
|
}
|
|
|
|
/** `mcpRpc`'s outcome. `unsupported` separates an older gateway that doesn't
|
|
* know the method from a real failure. */
|
|
interface McpRpcResult {
|
|
error?: string
|
|
ok: boolean
|
|
result?: McpServerPayload
|
|
unsupported?: boolean
|
|
}
|
|
|
|
async function mcpRpc(method: string, params: Record<string, unknown>): Promise<McpRpcResult> {
|
|
// Returns { ok, result } or { ok:false, unsupported:true } when the gateway
|
|
// doesn't know the method (older backend) vs a real error.
|
|
try {
|
|
const res = await host.request<McpServerPayload>(method, params)
|
|
|
|
return {
|
|
ok: true,
|
|
result: res
|
|
}
|
|
} catch (err: any) {
|
|
const msg = String((err && err.message) || err || '')
|
|
|
|
if (/unknown method/i.test(msg)) {
|
|
return {
|
|
ok: false,
|
|
unsupported: true
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: false,
|
|
error: msg
|
|
}
|
|
}
|
|
}
|
|
|
|
// Probe whether the new lifecycle RPCs exist on this gateway (cached per session).
|
|
let _mcpRpcSupported: boolean | null = null
|
|
|
|
async function mcpSetupSupported(): Promise<boolean> {
|
|
if (_mcpRpcSupported !== null) {
|
|
return _mcpRpcSupported
|
|
}
|
|
|
|
const r = await mcpRpc('mcp.servers.list', {})
|
|
_mcpRpcSupported = !(r.ok === false && r.unsupported)
|
|
|
|
return _mcpRpcSupported
|
|
}
|
|
|
|
/** One row of the capability pane's MCP list (catalog entry or installed server). */
|
|
interface McpCatalogEntry {
|
|
auth?: null | string
|
|
fromCatalog?: boolean
|
|
installed?: boolean
|
|
name: string
|
|
requires?: string[]
|
|
}
|
|
|
|
/** The capability scope the Edit Profile / New Bot panes hand down — the SDK's
|
|
* `ProfileScope`: a bare profile name, or a connection-qualified scope for a
|
|
* source-scoped bot. */
|
|
type McpSetupScope = null | string | undefined | { connectionId?: null | string; profile?: null | string }
|
|
|
|
interface McpSetupButtonProps {
|
|
ensureProfile?: () => Promise<null | string>
|
|
entry: McpCatalogEntry
|
|
onDone?: () => void
|
|
// TODO(bot-mode-types): Edit Profile passes `botBackendProfileScope(...)`, which
|
|
// is a `{ connectionId, profile }` OBJECT for every source-scoped bot. That
|
|
// object is forwarded verbatim as the `profile` param of mcp.servers.add /
|
|
// set_api_key / test / oauth.*, where the gateway expects a profile NAME
|
|
// string — and mcpRpc goes through host.request, so the connection isn't
|
|
// routed either. Typed as-written.
|
|
profile: McpSetupScope
|
|
}
|
|
|
|
export function McpSetupButton({ profile, entry, onDone, ensureProfile }: McpSetupButtonProps) {
|
|
const { t } = useI18n()
|
|
// entry: { name, requires:[env keys], auth?, fromCatalog, installed }
|
|
// profile may be null at first (New Bot: the profile isn't created yet).
|
|
// ensureProfile() lazily creates it on the first setup action and returns the
|
|
// slug, so OAuth / API-key setup works DURING creation, not only in Edit.
|
|
const [phase, setPhase] = useState<'busy' | 'done' | 'error' | 'idle' | 'keys' | 'oauth'>('idle') // idle | keys | oauth | busy | done | error
|
|
const [supported, setSupported] = useState<boolean | null>(null)
|
|
const [keyValues, setKeyValues] = useState<Record<string, string>>({})
|
|
const [message, setMessage] = useState('')
|
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
// Holds ONLY the profile this component created on demand. The live prop
|
|
// wins wherever both exist, so there is nothing to mirror into the ref and
|
|
// no render of lag between the parent supplying a profile and us using it.
|
|
const createdProfileRef = useRef<McpSetupScope>(null)
|
|
|
|
// Resolve the target profile, creating it on demand for the New Bot flow.
|
|
const resolveProfile = async () => {
|
|
const known = profile || createdProfileRef.current
|
|
|
|
if (known) {
|
|
return known
|
|
}
|
|
|
|
if (ensureProfile) {
|
|
const created = await ensureProfile()
|
|
|
|
if (created) {
|
|
createdProfileRef.current = created
|
|
}
|
|
|
|
return created
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
// eslint-disable-next-line no-restricted-syntax -- clears a timer handle on unmount, not an atom mirror
|
|
useEffect(() => {
|
|
let alive = true
|
|
mcpSetupSupported().then(ok => {
|
|
if (alive) {
|
|
setSupported(ok)
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
alive = false
|
|
|
|
if (pollRef.current) {
|
|
clearInterval(pollRef.current)
|
|
pollRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
const isOAuth = (entry.auth || '').toLowerCase() === 'oauth'
|
|
const requires = entry.requires || []
|
|
|
|
const beginKeys = async () => {
|
|
// Ensure the server exists in the target profile first (add from catalog).
|
|
setPhase('busy')
|
|
setMessage('')
|
|
const profile = await resolveProfile()
|
|
|
|
if (!profile) {
|
|
setPhase('idle')
|
|
|
|
return
|
|
}
|
|
|
|
if (entry.fromCatalog && !entry.installed) {
|
|
const add = await mcpRpc('mcp.servers.add', {
|
|
profile,
|
|
name: entry.name,
|
|
preset: entry.name
|
|
})
|
|
|
|
if (!add.ok) {
|
|
setPhase('error')
|
|
setMessage(add.error || 'Could not add server')
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
setPhase(isOAuth ? 'oauth' : 'keys')
|
|
}
|
|
|
|
const submitKeys = async () => {
|
|
setPhase('busy')
|
|
const target = profile || createdProfileRef.current
|
|
|
|
if (!target) {
|
|
setPhase('error')
|
|
setMessage('No target profile')
|
|
|
|
return
|
|
}
|
|
|
|
for (const k of requires) {
|
|
const val = (keyValues[k] || '').trim()
|
|
|
|
if (!val) {
|
|
continue
|
|
}
|
|
|
|
const r = await mcpRpc('mcp.servers.set_api_key', {
|
|
profile: target,
|
|
name: entry.name,
|
|
env_var: k,
|
|
value: val
|
|
})
|
|
|
|
if (!r.ok) {
|
|
setPhase('error')
|
|
setMessage(r.error || 'Failed to set ' + k)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
// Verify via test.
|
|
const t = await mcpRpc('mcp.servers.test', {
|
|
profile: target,
|
|
name: entry.name
|
|
})
|
|
|
|
if (t.ok && t.result && (t.result.ok || (t.result.result && t.result.result.ok))) {
|
|
setPhase('done')
|
|
host.notify({
|
|
kind: 'success',
|
|
message: entry.name + ' configured'
|
|
})
|
|
onDone && onDone()
|
|
} else {
|
|
setPhase('error')
|
|
setMessage(
|
|
(t.result && (t.result.error || (t.result.result && t.result.result.error))) || 'Server test failed after setup'
|
|
)
|
|
}
|
|
}
|
|
|
|
const beginOAuth = async () => {
|
|
// A second click (retry, impatient double-click) must not orphan the
|
|
// previous poll interval — an overwritten pollRef leaks a 2s poller that
|
|
// runs until unmount and can flip phase from a stale OAuth session.
|
|
if (pollRef.current) {
|
|
clearInterval(pollRef.current)
|
|
pollRef.current = null
|
|
}
|
|
|
|
setPhase('busy')
|
|
setMessage('')
|
|
const profile = await resolveProfile()
|
|
|
|
if (!profile) {
|
|
setPhase('idle')
|
|
|
|
return
|
|
}
|
|
|
|
if (entry.fromCatalog && !entry.installed) {
|
|
const add = await mcpRpc('mcp.servers.add', {
|
|
profile,
|
|
name: entry.name,
|
|
preset: entry.name
|
|
})
|
|
|
|
if (!add.ok) {
|
|
setPhase('error')
|
|
setMessage(add.error || 'Could not add server')
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
// Client-side callback listener (electron/mcp-oauth-callback-ipc.ts): the
|
|
// browser always runs on THIS machine, so hosting the OAuth redirect here
|
|
// works for local AND remote backends alike. Against a remote backend it
|
|
// is the only working flow — the gateway's own 127.0.0.1 listener is on
|
|
// the backend host, unreachable from this machine's browser. Falls back
|
|
// to the legacy gateway-listener flow when the bridge or the gateway-side
|
|
// callback RPC is unavailable (older builds).
|
|
const mcpOauthBridge =
|
|
typeof window !== 'undefined' && window.hermesDesktop && window.hermesDesktop.mcpOauth
|
|
? window.hermesDesktop.mcpOauth
|
|
: null
|
|
|
|
let listener: { id: string; redirectUri: string } | null = null
|
|
|
|
if (mcpOauthBridge) {
|
|
try {
|
|
listener = await mcpOauthBridge.listen()
|
|
} catch {
|
|
listener = null
|
|
}
|
|
}
|
|
|
|
let start = await mcpRpc('mcp.servers.oauth.start', {
|
|
profile,
|
|
name: entry.name,
|
|
...(listener ? { client_redirect_uri: listener.redirectUri } : {})
|
|
})
|
|
|
|
// Older gateway rejecting the loopback URI shape (or a stale build that
|
|
// validates differently): retry once on the legacy gateway-listener path.
|
|
if (!start.ok && listener) {
|
|
try {
|
|
await mcpOauthBridge!.cancel(listener.id)
|
|
} catch {
|
|
/* listener teardown is best-effort */
|
|
}
|
|
|
|
listener = null
|
|
start = await mcpRpc('mcp.servers.oauth.start', {
|
|
profile,
|
|
name: entry.name
|
|
})
|
|
}
|
|
|
|
const payload = start.result && (start.result.result || start.result)
|
|
const authUrl = payload && (payload.auth_url || payload.verification_url)
|
|
const sessionId = payload && payload.session_id
|
|
|
|
if (!start.ok || !authUrl || !sessionId) {
|
|
if (listener) {
|
|
try {
|
|
await mcpOauthBridge!.cancel(listener.id)
|
|
} catch {
|
|
/* listener teardown is best-effort */
|
|
}
|
|
}
|
|
|
|
setPhase('error')
|
|
setMessage(start.error || 'Could not start OAuth')
|
|
|
|
return
|
|
}
|
|
|
|
// With a client listener bound: await the provider redirect here and relay
|
|
// code/state to the gateway. Runs concurrently with the status poll below;
|
|
// errors surface through the poll (the gateway marks the flow failed).
|
|
if (listener) {
|
|
const listenerId = listener.id
|
|
|
|
void (async () => {
|
|
const cb = await mcpOauthBridge!.wait(listenerId)
|
|
|
|
if (cb.error === 'cancelled') {
|
|
return
|
|
}
|
|
|
|
const relay = await mcpRpc('mcp.servers.oauth.callback', {
|
|
profile,
|
|
name: entry.name,
|
|
session_id: sessionId,
|
|
code: cb.code || undefined,
|
|
state: cb.state || undefined,
|
|
error: cb.error || undefined
|
|
})
|
|
|
|
const rp = relay.result && (relay.result.result || relay.result)
|
|
|
|
if (!relay.ok || (rp && rp.ok === false)) {
|
|
setPhase('error')
|
|
setMessage((rp && rp.error_message) || relay.error || 'OAuth callback relay failed')
|
|
}
|
|
})()
|
|
}
|
|
|
|
// Open the auth URL in the native browser, same as provider OAuth.
|
|
// TODO(bot-mode-types): the plugin SDK's `host` has no `openExternal`, so this
|
|
// branch is dead and the window bridge / window.open fallbacks are the only
|
|
// live paths. `ctx.os.openExternal` is the real verb. Kept as-written under
|
|
// ts-expect-error, which reports itself as unused the day the SDK grows one.
|
|
try {
|
|
// @ts-expect-error TODO(bot-mode-types): not on the SDK host, branch is dead
|
|
if (host.openExternal) {
|
|
// @ts-expect-error TODO(bot-mode-types): not on the SDK host, branch is dead
|
|
host.openExternal(authUrl)
|
|
} else if (typeof window !== 'undefined' && window.hermesDesktop && window.hermesDesktop.openExternal) {
|
|
window.hermesDesktop.openExternal(authUrl)
|
|
} else {
|
|
window.open(authUrl, '_blank')
|
|
}
|
|
} catch {
|
|
/* fall through to poll; user can open the URL from the toast */
|
|
}
|
|
|
|
setPhase('oauth')
|
|
setMessage('Complete sign-in in your browser...')
|
|
pollRef.current = setInterval(async () => {
|
|
const poll = await mcpRpc('mcp.servers.oauth.poll', {
|
|
profile,
|
|
name: entry.name,
|
|
session_id: sessionId
|
|
})
|
|
|
|
const pd = poll.result && (poll.result.result || poll.result)
|
|
const status = pd && pd.status
|
|
|
|
if (status === 'approved') {
|
|
clearInterval(pollRef.current!)
|
|
pollRef.current = null
|
|
setPhase('done')
|
|
host.notify({
|
|
kind: 'success',
|
|
message: entry.name + ' authenticated'
|
|
})
|
|
onDone && onDone()
|
|
} else if (status === 'error') {
|
|
clearInterval(pollRef.current!)
|
|
pollRef.current = null
|
|
setPhase('error')
|
|
setMessage((pd && pd.error_message) || 'OAuth failed')
|
|
}
|
|
}, 2000)
|
|
}
|
|
|
|
if (supported === false) {
|
|
return (
|
|
<span className="ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)">
|
|
{'needs setup (' + requires.join(', ') + ') \u2014 restart the gateway to enable in-app setup'}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
if (phase === 'done') {
|
|
return <span className="ml-1.5 text-[0.65rem] text-(--ui-success)">set up ✓</span>
|
|
}
|
|
|
|
if (phase === 'keys') {
|
|
return (
|
|
<div className="mt-1 grid gap-1">
|
|
{requires.map(k => (
|
|
<Input
|
|
className="h-6 text-[0.7rem]"
|
|
key={k}
|
|
onChange={e =>
|
|
setKeyValues(prev => ({
|
|
...prev,
|
|
[k]: e.target.value
|
|
}))
|
|
}
|
|
placeholder={k}
|
|
type="password"
|
|
value={keyValues[k] || ''}
|
|
/>
|
|
))}
|
|
<div className="flex gap-1">
|
|
<Button onClick={() => void submitKeys()} size="xs" variant="secondary">
|
|
Save & test
|
|
</Button>
|
|
<Button onClick={() => setPhase('idle')} size="xs" variant="ghost">
|
|
{t.common.cancel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (phase === 'oauth') {
|
|
return <span className="ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)">{message || 'Authorizing\u2026'}</span>
|
|
}
|
|
|
|
if (phase === 'busy') {
|
|
return <span className="ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)">Working…</span>
|
|
}
|
|
|
|
if (phase === 'error') {
|
|
return (
|
|
<span className="ml-1.5 text-[0.65rem] text-(--ui-danger,#f87171)">
|
|
{(message || 'Setup failed') + ' '}
|
|
<Button className="underline" onClick={() => setPhase('idle')} size="inline" variant="link">
|
|
retry
|
|
</Button>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// idle
|
|
return (
|
|
<Button
|
|
className="ml-1.5 text-[0.65rem] text-(--ui-accent) underline"
|
|
onClick={() => void (isOAuth ? beginOAuth() : beginKeys())}
|
|
size="inline"
|
|
variant="link"
|
|
>
|
|
{isOAuth ? 'Sign in\u2026' : 'Set up\u2026'}
|
|
</Button>
|
|
)
|
|
}
|