635 lines
19 KiB
Svelte
635 lines
19 KiB
Svelte
<script lang="ts">
|
|
import { userWorkspaces, workspaceStore, type UserWorkspace } from '$lib/stores'
|
|
import { Badge, Button } from '../common'
|
|
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
|
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
|
|
import Toggle from '../Toggle.svelte'
|
|
import Popover from '../Popover.svelte'
|
|
import {
|
|
FlowService,
|
|
IntegrationService,
|
|
ScriptService,
|
|
UserService,
|
|
type NewToken
|
|
} from '$lib/gen'
|
|
import MultiSelect from '../select/MultiSelect.svelte'
|
|
import { safeSelectItems } from '../select/utils.svelte'
|
|
import TokenDisplay from './TokenDisplay.svelte'
|
|
import ScopeSelector from './ScopeSelector.svelte'
|
|
import Alert from '../common/alert/Alert.svelte'
|
|
import FolderPicker from '../FolderPicker.svelte'
|
|
import TextInput from '../text_input/TextInput.svelte'
|
|
import Select from '../select/Select.svelte'
|
|
import { mcpEndpointTools } from '$lib/mcpEndpointTools'
|
|
|
|
interface Props {
|
|
showMcpMode?: boolean
|
|
openWithMcpMode?: boolean
|
|
newTokenLabel?: string
|
|
defaultNewTokenWorkspace?: string
|
|
scopes?: string[]
|
|
onTokenCreated: (token: string) => void
|
|
displayCreateToken?: boolean
|
|
}
|
|
|
|
let {
|
|
showMcpMode = false,
|
|
defaultNewTokenWorkspace,
|
|
scopes,
|
|
onTokenCreated,
|
|
newTokenLabel = $bindable(undefined)
|
|
}: Props = $props()
|
|
|
|
// MCP clients do not allow names longer than 60 characters, here we use 55 because final tool name server side will add ~5 characters
|
|
const MAX_PATH_LENGTH = 55
|
|
|
|
let newToken = $state<string | undefined>(undefined)
|
|
let newMcpToken = $state<string | undefined>(undefined)
|
|
let newTokenExpiration = $state<number | undefined>(undefined)
|
|
let newTokenWorkspace = $state<string | undefined>(defaultNewTokenWorkspace)
|
|
let newMcpApps = $state<string[]>([])
|
|
let mcpCreationMode = $state(false)
|
|
let newMcpScope = $state('favorites')
|
|
let loadingApps = $state(false)
|
|
let errorFetchApps = $state(false)
|
|
let allApps = $state<string[]>([])
|
|
let loadingRunnables = $state(false)
|
|
let includedRunnables = $state<string[]>([])
|
|
let selectedFolder = $state<string>('')
|
|
|
|
// Granular scope selection
|
|
let selectedScripts = $state<string[]>([])
|
|
let selectedFlows = $state<string[]>([])
|
|
let selectedEndpoints = $state<string[]>([])
|
|
let allScripts = $state<string[]>([])
|
|
let allFlows = $state<string[]>([])
|
|
|
|
let runnablesCache = new Map<string, string[]>()
|
|
|
|
let customScopes = $state<string[]>([])
|
|
let showCustomScopes = $state(false)
|
|
|
|
function ensureCurrentWorkspaceIncluded(
|
|
workspacesList: UserWorkspace[],
|
|
currentWorkspace: string | undefined
|
|
) {
|
|
if (!currentWorkspace) {
|
|
return workspacesList
|
|
}
|
|
const hasCurrentWorkspace = workspacesList.some((w) => w.id === currentWorkspace)
|
|
if (hasCurrentWorkspace) {
|
|
return workspacesList
|
|
}
|
|
return [{ id: currentWorkspace, name: currentWorkspace }, ...workspacesList]
|
|
}
|
|
|
|
async function createToken(mcpMode: boolean = false): Promise<void> {
|
|
try {
|
|
let date: Date | undefined
|
|
if (newTokenExpiration) {
|
|
date = new Date(new Date().getTime() + newTokenExpiration * 1000)
|
|
}
|
|
|
|
let tokenScopes = scopes
|
|
if (mcpMode) {
|
|
if (newMcpScope === 'custom') {
|
|
// Granular scope format
|
|
tokenScopes = []
|
|
if (selectedScripts.length > 0) {
|
|
tokenScopes.push(`mcp:scripts:${selectedScripts.join(',')}`)
|
|
}
|
|
if (selectedFlows.length > 0) {
|
|
tokenScopes.push(`mcp:flows:${selectedFlows.join(',')}`)
|
|
}
|
|
if (selectedEndpoints.length > 0) {
|
|
tokenScopes.push(`mcp:endpoints:${selectedEndpoints.join(',')}`)
|
|
}
|
|
} else if (newMcpScope === 'folder') {
|
|
const folderPath = `f/${selectedFolder}/*`
|
|
tokenScopes = [`mcp:scripts:${folderPath}`, `mcp:flows:${folderPath}`, `mcp:endpoints:*`]
|
|
} else {
|
|
tokenScopes = [`mcp:${newMcpScope}`]
|
|
}
|
|
if (newMcpApps.length > 0) {
|
|
tokenScopes.push(`mcp:hub:${newMcpApps.join(',')}`)
|
|
}
|
|
} else if (showCustomScopes && customScopes.length > 0) {
|
|
tokenScopes = customScopes
|
|
}
|
|
|
|
const createdToken = await UserService.createToken({
|
|
requestBody: {
|
|
label: newTokenLabel,
|
|
expiration: date?.toISOString(),
|
|
scopes: tokenScopes,
|
|
workspace_id: mcpMode ? newTokenWorkspace || $workspaceStore : newTokenWorkspace
|
|
} as NewToken
|
|
})
|
|
|
|
if (mcpMode) {
|
|
newMcpToken = `${createdToken}`
|
|
} else {
|
|
newToken = `${createdToken}`
|
|
}
|
|
|
|
onTokenCreated(newToken ?? newMcpToken ?? '')
|
|
mcpCreationMode = false
|
|
} catch (err) {
|
|
console.error('Failed to create token:', err)
|
|
}
|
|
}
|
|
|
|
const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $workspaceStore))
|
|
const mcpBaseUrl = $derived(`${window.location.origin}/api/mcp/w/${newTokenWorkspace}/sse?token=`)
|
|
|
|
const warning = $derived(
|
|
newMcpScope === 'all'
|
|
? 'Create your first scripts or flows to make them available via MCP.'
|
|
: newMcpScope === 'favorites'
|
|
? `You do not have any favorite scripts or flows. You can favorite some scripts and flows to include them, or change the scope to "All scripts/flows" to include all your scripts and flows.`
|
|
: `You do not have any scripts or flows in the selected folder.`
|
|
)
|
|
const longPathRunnables = $derived(
|
|
includedRunnables.filter((path) => path.length > MAX_PATH_LENGTH)
|
|
)
|
|
const validRunnables = $derived(
|
|
includedRunnables.filter((path) => path.length <= MAX_PATH_LENGTH)
|
|
)
|
|
const longPathWarning = $derived(
|
|
longPathRunnables.length > 0
|
|
? `${longPathRunnables.length} script(s)/flow(s) have paths longer than 60 characters and will be excluded from MCP tools. Consider shortening the paths: ${longPathRunnables.slice(0, 3).join(', ')}${longPathRunnables.length > 3 ? ` and ${longPathRunnables.length - 3} more` : ''}`
|
|
: ''
|
|
)
|
|
|
|
$effect(() => {
|
|
if (mcpCreationMode) {
|
|
getAllApps()
|
|
} else {
|
|
newMcpApps = []
|
|
}
|
|
})
|
|
|
|
async function getAllApps() {
|
|
if (allApps.length > 0) {
|
|
return
|
|
}
|
|
try {
|
|
loadingApps = true
|
|
allApps = (
|
|
await IntegrationService.listHubIntegrations({
|
|
kind: 'script'
|
|
})
|
|
).map((x) => x.name)
|
|
} catch (err) {
|
|
console.error('Hub is not available')
|
|
allApps = []
|
|
errorFetchApps = true
|
|
} finally {
|
|
loadingApps = false
|
|
}
|
|
}
|
|
|
|
async function getScripts(
|
|
favoriteOnly: boolean = false,
|
|
workspace: string,
|
|
folder: string | undefined
|
|
) {
|
|
if (!workspace) {
|
|
return []
|
|
}
|
|
const pathStart = folder ? `f/${folder}` : undefined
|
|
const scripts = await ScriptService.listScripts({
|
|
starredOnly: favoriteOnly,
|
|
workspace,
|
|
pathStart,
|
|
withoutDescription: true
|
|
})
|
|
return scripts.map((x) => x.path)
|
|
}
|
|
|
|
async function getFlows(
|
|
favoriteOnly: boolean = false,
|
|
workspace: string,
|
|
folder: string | undefined
|
|
) {
|
|
if (!workspace) {
|
|
return []
|
|
}
|
|
const pathStart = folder ? `f/${folder}` : undefined
|
|
const flows = await FlowService.listFlows({
|
|
starredOnly: favoriteOnly,
|
|
workspace,
|
|
pathStart,
|
|
withoutDescription: true
|
|
})
|
|
return flows.map((x) => x.path)
|
|
}
|
|
|
|
async function getScriptsAndFlows(
|
|
favoriteOnly: boolean = false,
|
|
workspace: string,
|
|
folder: string | undefined
|
|
) {
|
|
const cacheKey = `${workspace}-${favoriteOnly}${folder ? `-${folder}` : ''}`
|
|
if (runnablesCache.has(cacheKey)) {
|
|
includedRunnables = runnablesCache.get(cacheKey) || []
|
|
return
|
|
}
|
|
|
|
try {
|
|
loadingRunnables = true
|
|
const [scripts, flows] = await Promise.all([
|
|
getScripts(favoriteOnly, workspace, folder),
|
|
getFlows(favoriteOnly, workspace, folder)
|
|
])
|
|
const combined = [...scripts, ...flows]
|
|
runnablesCache.set(cacheKey, combined)
|
|
includedRunnables = combined
|
|
} finally {
|
|
loadingRunnables = false
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (mcpCreationMode) {
|
|
const workspace = newTokenWorkspace || $workspaceStore
|
|
if (workspace) {
|
|
const folderParam = selectedFolder.length > 0 ? selectedFolder : undefined
|
|
getScriptsAndFlows(newMcpScope === 'favorites', workspace, folderParam)
|
|
}
|
|
} else {
|
|
includedRunnables = []
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
if (mcpCreationMode && newMcpScope !== 'folder') {
|
|
selectedFolder = ''
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
if (mcpCreationMode && newMcpScope === 'custom') {
|
|
const workspace = newTokenWorkspace || $workspaceStore
|
|
if (workspace) {
|
|
loadAllScriptsAndFlows(workspace)
|
|
}
|
|
}
|
|
})
|
|
|
|
async function loadAllScriptsAndFlows(workspace: string) {
|
|
try {
|
|
loadingRunnables = true
|
|
const [scripts, flows] = await Promise.all([
|
|
getScripts(false, workspace, undefined),
|
|
getFlows(false, workspace, undefined)
|
|
])
|
|
allScripts = scripts
|
|
allFlows = flows
|
|
} finally {
|
|
loadingRunnables = false
|
|
}
|
|
}
|
|
|
|
function selectAllScripts() {
|
|
selectedScripts = [...allScripts]
|
|
}
|
|
|
|
function clearAllScripts() {
|
|
selectedScripts = []
|
|
}
|
|
|
|
function selectAllFlows() {
|
|
selectedFlows = [...allFlows]
|
|
}
|
|
|
|
function clearAllFlows() {
|
|
selectedFlows = []
|
|
}
|
|
|
|
function selectAllEndpoints() {
|
|
selectedEndpoints = [...mcpEndpointTools.map((e) => e.name)]
|
|
}
|
|
|
|
function clearAllEndpoints() {
|
|
selectedEndpoints = []
|
|
}
|
|
</script>
|
|
|
|
<div>
|
|
<div class="p-4 rounded-md mb-6 min-w-min bg-surface-tertiary">
|
|
<h3 class="pb-2 font-semibold text-emphasis text-sm">Add a new token</h3>
|
|
|
|
{#if showMcpMode}
|
|
<div
|
|
class="mb-4 flex flex-row flex-shrink-0"
|
|
use:triggerableByAI={{
|
|
id: 'account-settings-create-mcp-token',
|
|
description: 'Create a new MCP token to authenticate to the Windmill API'
|
|
}}
|
|
>
|
|
<Toggle
|
|
on:change={(e) => {
|
|
mcpCreationMode = e.detail
|
|
if (e.detail) {
|
|
newTokenLabel = 'MCP token'
|
|
newTokenExpiration = undefined
|
|
newTokenWorkspace = $workspaceStore
|
|
} else {
|
|
newTokenLabel = undefined
|
|
newTokenExpiration = undefined
|
|
newTokenWorkspace = defaultNewTokenWorkspace
|
|
}
|
|
}}
|
|
checked={mcpCreationMode}
|
|
options={{
|
|
right: 'Generate MCP URL',
|
|
rightTooltip:
|
|
'Generate a new MCP URL to make your scripts, flows, and API endpoints available as tools through your LLM clients.',
|
|
rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/mcp'
|
|
}}
|
|
size="xs"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if scopes != undefined}
|
|
<div class="mb-4">
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold">Scope</span>
|
|
{#each scopes as scope}
|
|
<TextInput inputProps={{ disabled: true }} value={scope} class="mb-2 w-full" />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !mcpCreationMode && (!scopes || scopes.length === 0)}
|
|
<div class="flex flex-col gap-2">
|
|
<Toggle
|
|
checked={showCustomScopes}
|
|
on:change={(e) => {
|
|
showCustomScopes = e.detail
|
|
}}
|
|
options={{
|
|
right: 'Limit token permissions',
|
|
rightTooltip:
|
|
'By default, tokens have full API access. Enable this to restrict the token to specific scopes.'
|
|
}}
|
|
size="xs"
|
|
/>
|
|
{#if showCustomScopes}
|
|
<ScopeSelector bind:selectedScopes={customScopes} />
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{#if mcpCreationMode}
|
|
<div class="col-span-2">
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold">Scope</span>
|
|
<ToggleButtonGroup bind:selected={newMcpScope} allowEmpty={false}>
|
|
{#snippet children({ item })}
|
|
<ToggleButton
|
|
{item}
|
|
value="favorites"
|
|
label="Favorites only"
|
|
tooltip="Make only your favorite scripts and flows available as tools"
|
|
/>
|
|
<ToggleButton
|
|
{item}
|
|
value="all"
|
|
label="All scripts/flows"
|
|
tooltip="Make all your scripts and flows available as tools"
|
|
/>
|
|
<ToggleButton
|
|
{item}
|
|
value="folder"
|
|
label="Folder"
|
|
tooltip="Make all scripts and flows in the selected folder available as tools"
|
|
/>
|
|
<ToggleButton
|
|
{item}
|
|
value="custom"
|
|
label="Custom"
|
|
tooltip="Select exactly which scripts, flows, and endpoints to expose"
|
|
/>
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
|
|
{#if newMcpScope === 'folder'}
|
|
<div>
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold">Select Folder</span>
|
|
<FolderPicker bind:folderName={selectedFolder} />
|
|
</div>
|
|
{/if}
|
|
|
|
<div>
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold">Hub scripts (optional)</span>
|
|
{#if loadingApps}
|
|
<div>Loading...</div>
|
|
{:else if errorFetchApps}
|
|
<div>Error fetching apps</div>
|
|
{:else}
|
|
<MultiSelect
|
|
items={safeSelectItems(allApps)}
|
|
placeholder="Select apps"
|
|
bind:value={newMcpApps}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<div>
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
|
|
<select bind:value={newTokenWorkspace} disabled={workspaces.length === 1} class="w-full">
|
|
{#each workspaces as workspace}
|
|
<option value={workspace.id}>{workspace.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
|
|
<div>
|
|
<span class="block mb-1 text-emphasis text-xs font-semibold"
|
|
>Label <span class="text-xs text-primary">(optional)</span></span
|
|
>
|
|
<TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" />
|
|
</div>
|
|
|
|
{#if !mcpCreationMode}
|
|
<div>
|
|
<span class="block mb-1 text-xs text-emphasis font-semibold"
|
|
>Expires In <span class="text-xs text-primary">(optional)</span></span
|
|
>
|
|
<Select
|
|
bind:value={newTokenExpiration}
|
|
placeholder="No expiration"
|
|
inputClass="w-full"
|
|
items={[
|
|
{ label: 'No expiration', value: undefined },
|
|
{ label: '15 minutes', value: 15 * 60 },
|
|
{ label: '30 minutes', value: 30 * 60 },
|
|
{ label: '1 hour', value: 1 * 60 * 60 },
|
|
{ label: '1 day', value: 1 * 24 * 60 * 60 },
|
|
{ label: '7 days', value: 7 * 24 * 60 * 60 },
|
|
{ label: '30 days', value: 30 * 24 * 60 * 60 },
|
|
{ label: '90 days', value: 90 * 24 * 60 * 60 }
|
|
]}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if mcpCreationMode && newMcpScope === 'custom'}
|
|
{#if loadingRunnables}
|
|
<div class="flex flex-col gap-2 col-span-2 pr-4">
|
|
<span class="block text-xs text-primary">Loading scripts and flows...</span>
|
|
<div class="flex flex-wrap gap-1">
|
|
<Badge rounded small color="dark-gray" baseClass="animate-skeleton">Loading...</Badge>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
{#snippet sectionHeader(label: string, selectAll: () => void, clearAll: () => void)}
|
|
<div class="flex items-center justify-between">
|
|
<span class="block text-xs font-semibold">{label}</span>
|
|
<div class="flex gap-2">
|
|
<Button size="xs2" on:click={selectAll}>Select All</Button>
|
|
<Button size="xs2" on:click={clearAll}>Clear All</Button>
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<div class="flex flex-col gap-2 col-span-2 pr-4">
|
|
<div class="flex flex-col gap-2">
|
|
{@render sectionHeader('Scripts', selectAllScripts, clearAllScripts)}
|
|
{#if allScripts.length > 0}
|
|
<MultiSelect
|
|
items={safeSelectItems(allScripts)}
|
|
placeholder="Select scripts"
|
|
bind:value={selectedScripts}
|
|
/>
|
|
{:else}
|
|
<p class="text-xs text-primary">No scripts available</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 mt-2">
|
|
{@render sectionHeader('Flows', selectAllFlows, clearAllFlows)}
|
|
{#if allFlows.length > 0}
|
|
<MultiSelect
|
|
items={safeSelectItems(allFlows)}
|
|
placeholder="Select flows"
|
|
bind:value={selectedFlows}
|
|
/>
|
|
{:else}
|
|
<p class="text-xs text-primary">No flows available</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-2 mt-2">
|
|
{@render sectionHeader('API Endpoints', selectAllEndpoints, clearAllEndpoints)}
|
|
<MultiSelect
|
|
items={safeSelectItems(mcpEndpointTools.map((e) => e.name))}
|
|
placeholder="Select endpoints"
|
|
bind:value={selectedEndpoints}
|
|
/>
|
|
</div>
|
|
|
|
<div class="text-xs text-primary mt-2">
|
|
Selected: {selectedScripts.length} scripts, {selectedFlows.length} flows, {selectedEndpoints.length}
|
|
endpoints
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{:else if mcpCreationMode && (newMcpScope !== 'folder' || selectedFolder.length > 0)}
|
|
{#if loadingRunnables}
|
|
<div class="flex flex-col gap-2 col-span-2 pr-4">
|
|
<span class="block text-xs text-primary"
|
|
>Scripts & Flows that will be available via MCP</span
|
|
>
|
|
<div class="flex flex-wrap gap-1">
|
|
<Badge rounded small color="dark-gray" baseClass="animate-skeleton">Loading...</Badge>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-col gap-2 col-span-2 pr-4">
|
|
{#if longPathWarning}
|
|
<Alert type="warning" title="Some paths are too long" size="xs">
|
|
{longPathWarning}
|
|
</Alert>
|
|
{/if}
|
|
<span class="block text-xs">Scripts & Flows that will be available via MCP</span>
|
|
<div class="flex flex-wrap gap-1">
|
|
{#if validRunnables.length > 0 && validRunnables.length <= 5}
|
|
{#each validRunnables as scriptOrFlow}
|
|
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
|
|
{/each}
|
|
{:else if validRunnables.length > 0}
|
|
{#each validRunnables.slice(0, 3) as scriptOrFlow}
|
|
<Badge rounded small color="blue">{scriptOrFlow}</Badge>
|
|
{/each}
|
|
<Badge rounded small color="dark-gray">
|
|
+{validRunnables.length - 3} more
|
|
</Badge>
|
|
{:else}
|
|
<p class="text-xs text-primary">
|
|
{warning}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<span class="block text-xs mt-2">API endpoint tools that will be available via MCP</span
|
|
>
|
|
<div class="flex flex-wrap gap-1">
|
|
{#each mcpEndpointTools as endpoint}
|
|
<Popover notClickable>
|
|
{#snippet text()}
|
|
<div class="flex flex-col gap-1">
|
|
<div class="text-xs">{endpoint.description}</div>
|
|
<div class="text-xs">
|
|
{endpoint.method}
|
|
{endpoint.path}
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
<Badge rounded small color="green">{endpoint.name}</Badge>
|
|
</Popover>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="mt-4 flex justify-end gap-2 flex-row">
|
|
<Button
|
|
on:click={() => {
|
|
mcpCreationMode = false
|
|
}}
|
|
variant="default"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
on:click={() => createToken(mcpCreationMode)}
|
|
disabled={mcpCreationMode &&
|
|
(newTokenWorkspace == undefined ||
|
|
(newMcpScope === 'folder' && !selectedFolder) ||
|
|
(newMcpScope === 'custom' &&
|
|
selectedScripts.length === 0 &&
|
|
selectedFlows.length === 0 &&
|
|
selectedEndpoints.length === 0))}
|
|
variant="accent"
|
|
>
|
|
New token
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if newToken}
|
|
<TokenDisplay token={newToken} />
|
|
{/if}
|
|
|
|
{#if newMcpToken}
|
|
<TokenDisplay token={newMcpToken} mcpUrl={`${mcpBaseUrl}${newMcpToken}`} />
|
|
{/if}
|
|
</div>
|