Skip to content

MCPs #91

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open

MCPs #91

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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ HF_TOKEN=
MODELS_FILE=

PUBLIC_SENTRY_URL=
PUBLIC_ENABLE_MCP=
1 change: 1 addition & 0 deletions eslint.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export default ts.config(
"**/package-lock.json",
"**/yarn.lock",
"context_length.json",
".claude/**/*",
],
},
{
Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"format": "prettier . --write .",
"clean": "rm -rf ./node_modules/ && rm -rf ./.svelte-kit/ && ni && echo 'Project cleaned!'",
"update-ctx-length": "jiti scripts/update-ctx-length.ts",
"test:unit": "vitest",
"test:unit": "vitest --browser.headless",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test"
},
Expand Down Expand Up @@ -60,7 +60,7 @@
"prettier-plugin-tailwindcss": "^0.6.11",
"runed": "^0.25.0",
"shiki": "^3.4.0",
"svelte": "^5.34.3",
"svelte": "^5.35.1",
"svelte-check": "^4.0.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.9",
Expand All @@ -75,10 +75,12 @@
},
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.13.3",
"@sentry/sveltekit": "^9.34.0",
"dequal": "^2.0.3",
"eslint-plugin-svelte": "^3.6.0",
"remult": "^3.0.2",
"tailwindcss-spring": "^1.0.1",
"typia": "^8.0.0"
},
"pnpm": {
Expand Down
652 changes: 585 additions & 67 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@import "tailwindcss";

@plugin '@tailwindcss/container-queries';
@plugin 'tailwindcss-spring';

@custom-variant dark (&:where(.dark, .dark *));

Expand Down
41 changes: 31 additions & 10 deletions src/lib/components/inference-playground/generation-config.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
<script lang="ts">
import type { ConversationClass } from "$lib/state/conversations.svelte.js";
import { structuredForbiddenProviders } from "$lib/state/models.svelte.js";
import { maxAllowedTokens } from "$lib/utils/business.svelte.js";
import { isNumber } from "$lib/utils/is.js";
import { watch } from "runed";
import IconX from "~icons/carbon/close";
import { GENERATION_CONFIG_KEYS, GENERATION_CONFIG_SETTINGS } from "./generation-config-settings.js";
import StructuredOutputModal from "./structured-output-modal.svelte";

import type { ConversationClass } from "$lib/state/conversations.svelte.js";
import { structuredForbiddenProviders } from "$lib/state/models.svelte.js";
import { maxAllowedTokens } from "$lib/utils/business.svelte.js";
import { isNumber } from "$lib/utils/is.js";
import { watch } from "runed";
import IconX from "~icons/carbon/close";
import { GENERATION_CONFIG_KEYS, GENERATION_CONFIG_SETTINGS } from "./generation-config-settings.js";
import MCPModal from "./mcp-modal.svelte";
import StructuredOutputModal from "./structured-output-modal.svelte";
import { mcpServers } from "$lib/state/mcps.svelte.js";
import { isMcpEnabled } from "$lib/constants.js";
interface Props {
conversation: ConversationClass;
classNames?: string;
Expand Down Expand Up @@ -43,6 +45,7 @@
}

let editingStructuredOutput = $state(false);
let editingMCP = $state(false);
</script>

<div class="flex flex-col gap-y-7 {classNames}">
Expand Down Expand Up @@ -122,6 +125,24 @@
</div>
</label>
{/if}

<!-- MCP Servers -->
{#if isMcpEnabled()}
<div class="mt-2 flex cursor-pointer items-center justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-gray-300">MCP Servers</span>
<div class="flex items-center gap-2">
{#if mcpServers.enabled.length > 0}
<span class="rounded-full bg-blue-100 px-2 py-1 text-xs text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{mcpServers.enabled.length} enabled
</span>
{/if}
<button class="btn-mini" type="button" onclick={() => (editingMCP = true)}> configure </button>
</div>
</div>
{/if}
</div>

<StructuredOutputModal {conversation} bind:open={editingStructuredOutput} />
<StructuredOutputModal {conversation} bind:open={editingStructuredOutput} />
{#if isMcpEnabled()}
<MCPModal bind:open={editingMCP} />
{/if}
110 changes: 110 additions & 0 deletions src/lib/components/inference-playground/mcp-card.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<script lang="ts">
import { mcpServers, type MCPServerEntity, type MCPFormData } from "$lib/state/mcps.svelte.js";
import { projects } from "$lib/state/projects.svelte.js";
import { extractDomain } from "$lib/utils/url.js";
import IconEdit from "~icons/carbon/edit";
import IconDelete from "~icons/carbon/trash-can";
import Switch from "../switch.svelte";
import McpForm from "./mcp-form.svelte";

interface Props {
server: MCPServerEntity;
}

let { server }: Props = $props();

let editing = $state(false);

async function deleteServer() {
await mcpServers.delete(server.id);

// Remove from project's enabled MCPs if it was enabled
const currentProject = projects.current;
if (!currentProject?.enabledMCPs?.includes(server.id)) return;
await projects.update({
...currentProject,
enabledMCPs: currentProject.enabledMCPs.filter(mcpId => mcpId !== server.id),
});
}

async function setEnabled(enabled: boolean) {
const currentProject = projects.current;
if (!currentProject) return;

const enabledMCPs = currentProject.enabledMCPs || [];
const newEnabledMCPs = enabled ? [...enabledMCPs, server.id] : enabledMCPs.filter(id => id !== server.id);

await projects.update({
...currentProject,
enabledMCPs: newEnabledMCPs,
});
}

const isEnabled = $derived(projects.current?.enabledMCPs?.includes(server.id) || false);

async function saveServer(formData: MCPFormData) {
await mcpServers.update({
...server,
...formData,
});
editing = false;
}

function getFaviconUrl(url: string): string {
const domain = extractDomain(url);
return `https://www.google.com/s2/favicons?domain=https://${domain}&sz=64`;
}

function urlWithoutSubpaths(url: string): string {
const urlObj = new URL(url);
return urlObj.origin;
}
</script>

<div class="rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<div class="flex justify-between">
<div>
<div class="flex items-center gap-1">
<img src={getFaviconUrl(server.url)} alt="Server Icon" class="size-4 rounded-full" />
<span class="font-bold">{server.name}</span>
</div>
<p class="mt-1 truncate text-sm dark:text-neutral-300">
<span class="rounded bg-blue-900 px-0.75 py-0.25 uppercase">
{server.protocol}
</span>
<span>
{urlWithoutSubpaths(server.url)}
</span>
</p>
{#if server.headers && Object.keys(server.headers).length > 0}
<p class="mt-1 text-xs dark:text-neutral-400">
Headers: {Object.keys(server.headers).length} configured
</p>
{/if}
</div>
<div class="flex flex-col items-end justify-between gap-2">
<Switch bind:value={() => isEnabled, v => setEnabled(v)} />
<div class="flex items-center gap-2">
{#if !editing}
<button class="btn-mini" onclick={() => (editing = true)}>
<IconEdit class="h-4 w-4" />
<span>Edit</span>
</button>
<button
class="btn-mini text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
onclick={() => deleteServer()}
>
<IconDelete class="h-4 w-4" />
<span>Delete</span>
</button>
{/if}
</div>
</div>
</div>

{#if editing}
<div class="mt-2 border-t border-neutral-500 pt-2 dark:border-neutral-700">
<McpForm {server} onSubmit={saveServer} onCancel={() => (editing = false)} />
</div>
{/if}
</div>
160 changes: 160 additions & 0 deletions src/lib/components/inference-playground/mcp-form.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<script lang="ts">
import { type MCPProtocol, type MCPServerEntity, type MCPFormData } from "$lib/state/mcps.svelte.js";
import { createFieldValidation } from "$lib/utils/form.svelte";
import { entries } from "$lib/utils/object.svelte";
import { isValidURL } from "$lib/utils/url.js";
import IconAdd from "~icons/carbon/add";
import IconCheck from "~icons/carbon/checkmark";
import IconDelete from "~icons/carbon/trash-can";

interface Props {
server?: MCPServerEntity;
onSubmit: (formData: MCPFormData) => Promise<void>;
onCancel: () => void;
submitLabel?: string;
}

let { server, onSubmit, onCancel, submitLabel = "Save" }: Props = $props();

let formState = $state({
name: server?.name || "",
url: server?.url || "",
protocol: (server?.protocol || "sse") as MCPProtocol,
headers: entries(server?.headers || {}),
});

const protocolOptions: MCPProtocol[] = ["sse", "http"];

const nameField = createFieldValidation({
validate: v => {
if (!v) return "Server name is required";
if (v.trim().length === 0) return "Server name cannot be empty";
},
});

const urlField = createFieldValidation({
validate: v => {
if (!v) return "Server URL is required";
if (v.trim().length === 0) return "Server URL cannot be empty";
if (!isValidURL(v)) return "Invalid URL";
},
});

const disabled = $derived(!nameField.valid || !urlField.valid);

async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (!nameField.valid || !urlField.valid) return;

await onSubmit({
...formState,
headers: formState.headers.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
});
}
</script>

<form class="space-y-3" onsubmit={handleSubmit}>
<label class="flex flex-col gap-2">
<p class="block text-sm font-medium text-gray-900 dark:text-white">
Server Name <span class="text-red-800 dark:text-red-300">*</span>
</p>
<input
type="text"
bind:value={formState.name}
class="input block w-full"
placeholder="My MCP Server"
{...nameField.attrs}
required
/>
<p class="text-xs text-red-300">{nameField.msg}</p>
</label>

<label class="flex flex-col gap-2">
<p class="block text-sm font-medium text-gray-900 dark:text-white">
Server URL <span class="text-red-800 dark:text-red-300">*</span>
</p>
<input
type="url"
bind:value={formState.url}
class="input block w-full"
placeholder="https://mcp.example.com/sse"
{...urlField.attrs}
required
/>
<p class="text-xs text-red-300">{urlField.msg}</p>
</label>

<div class="flex flex-col gap-2">
<p class="block text-sm font-medium text-gray-900 dark:text-white">Protocol</p>
<div class="flex rounded-lg bg-gray-100 p-1 dark:bg-gray-700" role="radiogroup" aria-label="Server Protocol">
{#each protocolOptions as protocol}
<label class="relative flex-1 cursor-pointer">
<input
type="radio"
name="protocol-option"
value={protocol}
bind:group={formState.protocol}
class="peer sr-only"
/>
<div
class="flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-gray-600 transition-colors duration-200 ease-in-out peer-checked:bg-white peer-checked:text-gray-900 peer-checked:shadow dark:text-gray-300 dark:peer-checked:bg-gray-800 dark:peer-checked:text-white"
>
{protocol.toUpperCase()}
</div>
<span
aria-hidden="true"
class="absolute inset-0 z-0 rounded-md transition-all duration-200 ease-in-out peer-focus:ring-2 peer-focus:ring-blue-500 peer-focus:ring-offset-2 peer-focus:ring-offset-gray-100 dark:peer-focus:ring-offset-gray-700"
></span>
</label>
{/each}
</div>
</div>

<div class="flex flex-col gap-2">
<p class="block text-sm font-medium text-gray-900 dark:text-white">Headers</p>
{#each formState.headers || [] as _, i (i)}
<div class="flex items-center gap-2">
<input
type="text"
bind:value={formState.headers[i]![0]}
class="flex-1 rounded-md border border-gray-300 bg-gray-50 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
placeholder="Header name"
/>
<span class="text-gray-500">:</span>
<input
type="text"
bind:value={formState.headers[i]![1]}
class="flex-1 rounded-md border border-gray-300 bg-gray-50 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
placeholder="Header value"
/>
<button
class="btn-sm !h-auto self-stretch text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
onclick={() => {
formState.headers.splice(i, 1);
}}
type="button"
>
<IconDelete class="h-4 w-4" />
</button>
</div>
{/each}
<button
class="btn-sm self-start"
type="button"
onclick={() => {
formState.headers.push(["", ""]);
formState = formState;
}}
>
<IconAdd class="size-4" />
Add Header
</button>
</div>

<div class="flex items-center gap-2">
<button class="btn-sm" {disabled}>
<IconCheck /><span>{submitLabel}</span>
</button>
<button class="btn-sm" type="button" onclick={onCancel}> Cancel </button>
</div>
</form>
Loading
Loading