feat: Integrate MCP with hub (#5685)

* draft

* working draft

* tool to list scripts

* working sse server

* add proxy + parse query params

* working mcp that runs script

* remove useless dependencies

* log context

* update crate

* mcp no proxy

* use custom sdk

* integrate in windmill api

* draft

* put rmcp sdk here

* do not use mcp as crate

* use extensions for get scripts

* remove unused crate

* list actual scripts

* fix

* give schema in list tools

* cleaning + take workspace id from context

* implement calling the script with tool

* cleaning + fix ctrl-c

* make post path a param

* cleaning

* better name for tools

* fix error with tool name

* cleaning

* draft cleaning

* more cleaning

* list script based on settings

* fix query

* add params in openapi

* use rmcp fork from git

* remove files

* cleaning + fix query

* remove settings and use favorite by default

* add flows in tools list

* handle running flows

* remove frontend for mcp settings

* handle resource args

* send list of resource in shema

* handle mcp url setting with token scope

* cleaning

* avoid calling list tools in call tool

* apply scope to flows

* cleaning

* cleaning

* cleaning

* cleaning

* format files

* fix typos

* remove log

* add back missing dispatch

* fix transform for resource-obj + put every resource in description

* transform obj to string

* cleaner code

* better frontend

* cleaner code

* cleaner logic

* add parentheses just in case

* add func to fetch hub scripts

* fix typos

* working fetch and run hub script

* also fetch flows from hub

* improvments

* merge create tool logic

* add integrations in description

* cleaning

* cleaning

* small fix

* get schema for flow

* filter tools fetch by token scope

* remove hub flows

* remove prints

* add hub script integration choice

* higher limits

* cleaning

* fix merge

* better naming for hub scripts

* no workspace for hub

* alow multiple app in one token

* plural

* fix

* cleaning

* add documentation

* fix bad code

* use id directly

* cleaner bindings

* fix disabled condition

* add cancel button + reset apps if not hub token

* reset mcp apps

* combine all/favorites + hub

* small fix
This commit is contained in:
centdix
2025-05-01 19:31:56 +02:00
committed by GitHub
parent a990a812fc
commit fda3c2a8ca
5 changed files with 667 additions and 193 deletions
@@ -103,7 +103,9 @@
}}
disabled={!$enterpriseLicense || !$superadmin}
>
Save {#if !$superadmin} <span class="text-2xs text-tertiary">superadmin only</span> {/if}
Save {#if !$superadmin}
<span class="text-2xs text-tertiary">superadmin only</span>
{/if}
</Button>
<span class="text-2xs text-tertiary"
@@ -1,30 +1,39 @@
<script lang="ts">
// @ts-ignore
import Portal from '$lib/components/Portal.svelte'
import { createFloatingActions } from 'svelte-floating-ui'
import { tick } from 'svelte'
import { offset, flip, shift } from 'svelte-floating-ui/dom'
import MultiSelect from '$lib/components/multiselect/MultiSelect.svelte'
import DarkModeObserver from '../DarkModeObserver.svelte'
import { deepEqual } from 'fast-equals'
let {
items,
value = $bindable([]),
placeholder = undefined,
target = undefined,
topPlacement = false,
allowUserOptions = undefined
} = $props<{
items: any[]
value?: string[]
placeholder?: string
target?: string | HTMLElement
topPlacement?: boolean
allowUserOptions?: boolean | 'append'
}>()
export let items: any[]
let propValue: string[] | undefined = []
export { propValue as value }
$: value = structuredClone(propValue)
export let placeholder: string | undefined = undefined
export let target: string | HTMLElement | undefined = undefined
export let topPlacement = false
export let allowUserOptions: boolean | 'append' | undefined = undefined
const [floatingRef, floatingContent] = createFloatingActions({
strategy: 'absolute',
placement: topPlacement ? 'top-start' : 'bottom-start',
middleware: [offset(5), flip(), shift()]
})
let outerDiv: HTMLDivElement | undefined = undefined
let portalRef: HTMLDivElement | undefined = undefined
let outerDiv = $state<HTMLDivElement | undefined>(undefined)
let portalRef = $state<HTMLDivElement | undefined>(undefined)
let darkMode = $state(false)
let w = $state(0)
let open = $state(false)
function moveOptionsToPortal() {
// Find ul element with class 'options' within the outerDiv
const ul = outerDiv?.querySelector('.options')
@@ -34,17 +43,13 @@
}
}
$: if (portalRef && outerDiv && (allowUserOptions || items?.length > 0)) {
tick().then(() => {
moveOptionsToPortal()
})
}
// bg-indigo-100 text-indigo-800 dark:bg-indigo-200 dark:text-indigo-900
let darkMode: boolean = false
let w = 0
let open: boolean = false
$effect(() => {
if (portalRef && outerDiv && (allowUserOptions || items?.length > 0)) {
tick().then(() => {
moveOptionsToPortal()
})
}
})
</script>
<DarkModeObserver bind:darkMode />
@@ -62,10 +67,14 @@
--sms-focus-border={'none'}
--sms-selected-bg={darkMode ? '#c7d2fe' : '#e0e7ff'}
--sms-selected-text-color={darkMode ? '#312e81' : '#3730a3'}
bind:selected={value}
on:change={() => {
propValue = value
}}
bind:selected={
() => [...value],
(newVal) => {
if (!deepEqual(value, newVal)) {
value = newVal
}
}
}
{placeholder}
options={items}
on:close={() => {
@@ -77,15 +86,19 @@
let:option
disableRemoveAll
>
<!-- needed because portal doesn't work for mouseup event en mobile -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="w-full text-sm"
on:mouseup|stopPropagation
on:pointerdown|stopPropagation={(e) => {
role="option"
tabindex="0"
onmouseup={(e) => {
e.stopPropagation()
}}
onpointerdown={(e) => {
e.stopPropagation()
let newe = new MouseEvent('mouseup')
e.target?.['parentElement']?.dispatchEvent(newe)
}}
aria-selected={value?.includes(option)}
>
{option}
</div>
@@ -93,13 +106,17 @@
</div>
<Portal {target} name="multi-select">
<div use:floatingContent class="z5000" hidden={!open}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={portalRef}
class="multiselect"
style={`min-width: ${w}px;`}
on:click|stopPropagation
onclick={(e) => {
e.stopPropagation()
}}
role="listbox"
tabindex="0"
></div>
</div>
</Portal>
@@ -2,7 +2,7 @@
import TableCustom from '$lib/components/TableCustom.svelte'
import { displayDate, copyToClipboard } from '$lib/utils'
import type { TruncatedToken, NewToken } from '$lib/gen'
import { UserService } from '$lib/gen'
import { IntegrationService, UserService } from '$lib/gen'
import { Button } from '$lib/components/common'
import { Clipboard, Plus } from 'lucide-svelte'
import { workspaceStore, userWorkspaces, type UserWorkspace } from '$lib/stores'
@@ -12,6 +12,7 @@
import Toggle from '../Toggle.svelte'
import ClipboardPanel from '../details/ClipboardPanel.svelte'
import { sendUserToast } from '$lib/toast'
import MultiSelectWrapper from '../multiselect/MultiSelectWrapper.svelte'
// --- Props ---
interface Props {
@@ -37,10 +38,14 @@
let newToken = $state<string | undefined>(undefined)
let newTokenExpiration = $state<number | undefined>(undefined)
let newTokenWorkspace = $state<string | undefined>(defaultNewTokenWorkspace)
let newMcpApps = $state<string[]>([])
let displayCreateToken = $state(scopes != undefined)
let mcpCreationMode = $state(false)
let newMcpScope = $state('favorites')
let newMcpToken = $state<string | undefined>(undefined)
let loadingApps = $state(false)
let errorFetchApps = $state(false)
let allApps = $state<string[]>([])
function ensureCurrentWorkspaceIncluded(
workspacesList: UserWorkspace[],
@@ -67,6 +72,14 @@
listTokens()
})
$effect(() => {
if (mcpCreationMode) {
getAllApps()
} else {
newMcpApps = []
}
})
// --- Functions ---
async function createToken(mcpMode: boolean = false): Promise<void> {
try {
@@ -75,7 +88,13 @@
date = new Date(new Date().getTime() + newTokenExpiration * 1000)
}
let tokenScopes = mcpMode ? [`mcp:${newMcpScope}`] : scopes
let tokenScopes = scopes
if (mcpMode) {
tokenScopes = [`mcp:${newMcpScope}`]
if (newMcpApps.length > 0) {
tokenScopes.push(`mcp:hub:${newMcpApps.join(',')}`)
}
}
const createdToken = await UserService.createToken({
requestBody: {
@@ -107,6 +126,7 @@
newMcpToken = undefined
newToken = undefined
newTokenExpiration = undefined
newMcpApps = []
newTokenLabel = type === 'mcpUrl' ? 'MCP token' : undefined
}
@@ -141,6 +161,26 @@
tokenPage -= 1
listTokens()
}
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
}
}
</script>
<div class="grid grid-cols-2 pt-8 pb-1" class:pt-8={scopes == undefined}>
@@ -192,66 +232,103 @@
{#if displayCreateToken}
<div class="py-3 px-3 border rounded-md mb-6 bg-surface-secondary min-w-min">
<h3 class="pb-3 font-semibold">Add a new token</h3>
{#if scopes != undefined}
{#each scopes as scope}
<div class="flex flex-col mb-4">
<label for="label">Scope</label>
<input disabled type="text" value={scope} />
</div>
{/each}
{/if}
{#if showMcpMode}
<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 and flows available as tools through your LLM clients.',
rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/mcp'
}}
class="mb-4"
size="xs"
/>
<div class="mb-4 flex flex-row flex-shrink-0">
<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 and flows available as tools through your LLM clients.',
rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/mcp'
}}
size="xs"
/>
</div>
{/if}
<div class="flex flex-row flex-wrap gap-2 w-full justify-between">
{#if scopes != undefined}
<div class="mb-4">
<span class="block mb-1">Scope</span>
{#each scopes as scope}
<input disabled type="text" value={scope} class="mb-2 w-full" />
{/each}
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#if mcpCreationMode}
<div class="flex flex-col">
<label for="label">Scope</label>
<div>
<span class="block mb-1">Scope</span>
<ToggleButtonGroup bind:selected={newMcpScope} allowEmpty={false} let:item>
<ToggleButton {item} value="favorites" label="Favorites Only" />
<ToggleButton {item} value="all" label="All Resources" />
<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"
/>
</ToggleButtonGroup>
</div>
<div class="flex flex-col">
<label for="label">Workspace</label>
<select bind:value={newTokenWorkspace} disabled={workspaces.length === 1}>
<div>
<span class="block mb-1">Hub scripts (optional)</span>
{#if loadingApps}
<div>Loading...</div>
{:else if errorFetchApps}
<div>Error fetching apps</div>
{:else}
<MultiSelectWrapper
items={allApps}
placeholder="Select apps"
bind:value={newMcpApps}
/>
{/if}
</div>
<div>
<span class="block mb-1">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 class="flex flex-col">
<label for="label">Label <span class="text-xs text-tertiary">(optional)</span></label>
<input type="text" bind:value={newTokenLabel} />
<div>
<span class="block mb-1">Label <span class="text-xs text-tertiary">(optional)</span></span
>
<input type="text" bind:value={newTokenLabel} class="w-full" />
</div>
<div class="flex flex-col">
<label for="expires"
>Expires In &nbsp;<span class="text-xs text-tertiary">(optional)</span>
</label>
<select bind:value={newTokenExpiration} disabled={mcpCreationMode}>
<div>
<span class="block mb-1"
>Expires In <span class="text-xs text-tertiary">(optional)</span></span
>
<select bind:value={newTokenExpiration} disabled={mcpCreationMode} class="w-full">
<option value={undefined}>No expiration</option>
<option value={15 * 60}>15m</option>
<option value={30 * 60}>30m</option>
@@ -262,15 +339,23 @@
<option value={90 * 24 * 60 * 60}>90d</option>
</select>
</div>
<div class="flex items-end">
<Button
btnClasses="!mt-2"
on:click={() => createToken(mcpCreationMode)}
disabled={mcpCreationMode && newTokenWorkspace === undefined}
>
New token
</Button>
</div>
</div>
<div class="mt-4 flex justify-end gap-2 flex-row">
<Button
on:click={() => {
mcpCreationMode = false
displayCreateToken = false
}}
>
Cancel
</Button>
<Button
on:click={() => createToken(mcpCreationMode)}
disabled={mcpCreationMode && newTokenWorkspace == undefined}
>
New token
</Button>
</div>
</div>
{/if}