Thomas G. Lopes
MCPs (#91)
52c6f5c unverified
<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>