<template>
<div class="mcp-keys-panel">
<div class="mcp-keys-header">
<h2 class="mcp-keys-title">MCP servers</h2>
</div>
<p class="mcp-keys-description">
Everything connected to your profiles. A server that offers a personal key
slot runs under your own key — leave yours unset and it keeps using the
shared key configured on this Navi.
</p>
<div v-if="serversStore.loading" class="mcp-keys-loading">
<GnLoader circle label="Loading MCP servers" />
</div>
<div v-else-if="!serversStore.servers.length" class="mcp-keys-empty">
<i class="ph ph-plugs-connected empty-icon" aria-hidden="true"></i>
<p>No MCP servers are connected to any profile on this Navi.</p>
</div>
<div v-else class="mcp-keys-rows">
<div
v-for="server in serversStore.servers"
:key="server.server_name"
class="mcp-keys-row"
:class="{ 'is-keyless': !server.accepts_user_key }"
>
<div class="mcp-keys-info">
<div class="mcp-keys-server">{{ server.server_name }}</div>
<div class="mcp-keys-where">
{{ server.transport }}
<template v-if="server.profiles.length"> · {{ server.profiles.join(', ') }}</template>
</div>
<div v-if="server.accepts_user_key" class="mcp-keys-slot">
<template v-if="server.has_key">
Personal key set {{ formatDate(server.updated_at) }} — used instead of
the shared key ({{ keyLocation(server) }}).
</template>
<template v-else>
No personal key — the shared key from the config is used
({{ keyLocation(server) }}).
</template>
</div>
<div v-else class="mcp-keys-noslot">
No personal key slot — runs on the shared config.
</div>
</div>
<template v-if="server.accepts_user_key">
<GnInput
v-model="keys[server.server_name]"
type="password"
:placeholder="server.has_key ? '••• saved — enter a new key to replace' : 'Paste your key'"
class="mcp-keys-input"
@keyup.enter="onSave(server)"
/>
<div class="mcp-keys-actions">
<GnButton
variant="primary"
size="sm"
:disabled="!keys[server.server_name] || serversStore.savingServer === server.server_name"
@click="onSave(server)"
>
Save
</GnButton>
<GnButton
v-if="server.has_key"
variant="ghost"
size="sm"
:disabled="serversStore.savingServer === server.server_name"
@click="onReset(server)"
>
Reset to default
</GnButton>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive } from 'vue'
import { useMcpKeysStore } from '@/stores/mcpKeys.js'
import { useConfirm } from '@/composables/useConfirm.js'
import { useToast } from 'gnexus-ui-kit/vue'
import { GnButton, GnInput, GnLoader } from 'gnexus-ui-kit/vue'
const serversStore = useMcpKeysStore()
const confirm = useConfirm()
const toast = useToast()
const keys = reactive({})
onMounted(() => {
serversStore.fetchServers()
})
function keyLocation(server) {
return server.key_type === 'header'
? `${server.key_location} header${server.prefix ? ` (${server.prefix}…)` : ''}`
: `${server.key_location} env`
}
async function onSave(server) {
const key = (keys[server.server_name] || '').trim()
if (!key) return
try {
await serversStore.saveKey(server.server_name, key)
keys[server.server_name] = ''
toast.success({ title: `Key saved for ${server.server_name}` })
} catch (err) {
toast.error({ title: 'Failed to save key', text: err.message })
}
}
async function onReset(server) {
const ok = await confirm(
`Reset the key for "${server.server_name}" to the shared default?`
)
if (!ok) return
try {
await serversStore.resetKey(server.server_name)
keys[server.server_name] = ''
toast.success({ title: `Key reset for ${server.server_name}` })
} catch (err) {
toast.error({ title: 'Failed to reset key', text: err.message })
}
}
function formatDate(iso) {
const d = new Date(iso)
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
}
</script>
<style scoped lang="scss">
@use 'kit-deps' as *;
.mcp-keys-panel {
margin-bottom: 24px;
}
.mcp-keys-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.mcp-keys-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
color: var(--gn-color-text-medium);
}
.mcp-keys-description {
margin: 0 0 16px;
font-size: 0.875rem;
color: var(--gn-color-text-medium);
}
.mcp-keys-loading {
padding: 24px;
text-align: center;
}
.mcp-keys-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 32px 24px;
border: 1px dashed var(--gn-color-border);
border-radius: var(--gn-radius-md);
text-align: center;
.empty-icon {
font-size: 2rem;
color: var(--gn-color-text-dimmed);
}
p {
margin: 0;
max-width: 52ch;
font-size: 0.875rem;
color: var(--gn-color-text-medium);
}
}
.mcp-keys-rows {
display: flex;
flex-direction: column;
gap: 12px;
}
.mcp-keys-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--gn-color-border);
border-radius: var(--gn-radius-md);
// Servers without a key slot are context, not controls — keep them quieter.
&.is-keyless {
border-color: var(--gn-border-color-muted);
background: var(--gn-surface-panel-muted, transparent);
}
}
.mcp-keys-info {
flex: 1 1 240px;
min-width: 0;
}
.mcp-keys-server {
font-size: 0.9rem;
color: var(--gn-color-text-medium);
}
.mcp-keys-where {
margin-top: 2px;
font-size: 0.8rem;
color: var(--gn-color-text-dark);
}
.mcp-keys-slot,
.mcp-keys-noslot {
margin-top: 4px;
font-size: 0.8rem;
color: var(--gn-color-text-dimmed);
}
// The keyless hint is informational only — dim it further than a slot line.
.mcp-keys-noslot {
color: var(--gn-color-text-dimmed);
opacity: 0.75;
}
.mcp-keys-input {
flex: 0 1 260px;
}
.mcp-keys-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
@media (max-width: 767px) {
.mcp-keys-row {
flex-direction: column;
align-items: stretch;
}
.mcp-keys-input {
flex: 1 1 auto;
}
}
</style>