Prechádzať zdrojové kódy

feat: add tool testing pages

Thomas Zhang 3 mesiacov pred
rodič
commit
d925e6e0b8

+ 66 - 2
src/lib/api/tools.ts

@@ -1,10 +1,33 @@
 import { resolve } from '$app/paths';
 
+import { handleResponse, type TestResponse } from '$lib/api/types';
 import type { ManagedToolCR } from '$lib/types/custom-resources';
 import type { Tool } from '$lib/types/entities';
 import type { Changes, CoreResources } from '$lib/types/workspace';
 
-import { handleResponse } from './types';
+type MCPTool = {
+	name: string;
+	description?: string;
+	inputSchema: object;
+};
+
+type GetToolListResponse = TestResponse<{
+	serverInfo?: { name: string; version: string };
+	protocolVersion?: string;
+	capabilities?: Record<string, boolean>;
+	tools: MCPTool[];
+}>;
+
+type ToolCallRequest = { tool: string; sessionId: string; arguments: Record<string, unknown> };
+
+type ToolCallContentItem =
+	| { type: 'text'; text: string }
+	| { type: 'image'; data: string; mimeType: string }
+	| { type: 'resource'; resource: { uri: string; mimeType?: string; text?: string } };
+
+type ToolCallResponse = TestResponse<{
+	content: ToolCallContentItem[];
+}>;
 
 const list = (workspaceId: string, workfetch = globalThis.fetch): Promise<Tool[]> =>
 	workfetch(
@@ -99,4 +122,45 @@ const remove = (workspaceId: string, id: string, fetch = globalThis.fetch): Prom
 		if (!res.ok) await handleResponse<void>(res);
 	});
 
-export { create, get, getChanges, getResources, getStatus, list, remove, update };
+const getToolList = (
+	workspaceId: string,
+	id: string,
+	fetch = globalThis.fetch,
+): Promise<GetToolListResponse> =>
+	fetch(resolve('/api/workspaces/[workspaceId]/tools/[id]/test', { workspaceId, id })).then(
+		handleResponse<GetToolListResponse>,
+	);
+
+const callTool = (
+	workspaceId: string,
+	id: string,
+	tool: string,
+	sessionId: string,
+	args: Record<string, unknown>,
+	fetch = globalThis.fetch,
+): Promise<ToolCallResponse> =>
+	fetch(resolve('/api/workspaces/[workspaceId]/tools/[id]/test', { workspaceId, id }), {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify({ tool, arguments: args, sessionId }),
+	}).then(handleResponse<ToolCallResponse>);
+
+export type {
+	GetToolListResponse,
+	MCPTool,
+	ToolCallContentItem,
+	ToolCallRequest,
+	ToolCallResponse,
+};
+export {
+	callTool,
+	create,
+	get,
+	getChanges,
+	getResources,
+	getStatus,
+	getToolList,
+	list,
+	remove,
+	update,
+};

+ 216 - 0
src/lib/components/tools/testing/call.svelte

@@ -0,0 +1,216 @@
+<script lang="ts">
+	import { Send } from '@lucide/svelte';
+
+	import type { MCPTool, ToolCallContentItem, ToolCallResponse } from '$lib/api/tools';
+	import * as toolsApi from '$lib/api/tools';
+	import { TestResult } from '$lib/components/common/test-result';
+	import { Button } from '$lib/components/controls/button';
+	import * as Resizable from '$lib/components/controls/resizable';
+	import { Spinner } from '$lib/components/controls/spinner';
+
+	let {
+		workspaceId,
+		toolId,
+		sessionId,
+		tool,
+	}: {
+		workspaceId: string;
+		toolId: string;
+		sessionId: string;
+		tool: MCPTool;
+	} = $props();
+
+	let argsText = $state('{}');
+	let response = $state<ToolCallResponse | undefined>(undefined);
+	let loading = $state(false);
+	let error = $state<{ type: string; message: string } | undefined>(undefined);
+	const schemaText = $derived(JSON.stringify(tool.inputSchema, null, 2));
+
+	$effect(() => {
+		if (tool) {
+			argsText = argsTemplate(tool);
+			response = undefined;
+			error = undefined;
+		}
+	});
+
+	type JsonSchema = {
+		type?: string | string[];
+		required?: string[];
+		properties?: Record<string, JsonSchema>;
+		items?: JsonSchema | JsonSchema[];
+		oneOf?: JsonSchema[];
+		anyOf?: JsonSchema[];
+		allOf?: JsonSchema[];
+	};
+
+	const argsTemplate = (tool: MCPTool): string => {
+		const pickSchema = (schema: JsonSchema): JsonSchema =>
+			schema.oneOf?.[0] ?? schema.anyOf?.[0] ?? schema.allOf?.[0] ?? schema;
+
+		const inferType = (schema: JsonSchema): string | undefined => {
+			if (Array.isArray(schema.type))
+				return schema.type.find((t) => t !== 'null') ?? schema.type[0];
+			if (schema.type) return schema.type;
+			if (schema.properties || schema.required) return 'object';
+			if (schema.items) return 'array';
+			return undefined;
+		};
+
+		const buildSkeleton = (rawSchema: JsonSchema): unknown => {
+			const schema = pickSchema(rawSchema);
+			const schemaType = inferType(schema);
+
+			if (schemaType === 'object') {
+				const out: Record<string, unknown> = {};
+				for (const key of schema.required ?? []) {
+					out[key] = buildSkeleton(schema.properties?.[key] ?? {});
+				}
+				return out;
+			}
+
+			if (schemaType === 'array') {
+				const itemSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
+				if (!itemSchema) return [];
+				return [buildSkeleton(itemSchema)];
+			}
+
+			return '';
+		};
+
+		const skeleton = buildSkeleton((tool.inputSchema as JsonSchema) ?? {});
+		return JSON.stringify(skeleton, null, 2);
+	};
+
+	const argsValid = $derived.by(() => {
+		try {
+			JSON.parse(argsText);
+			return true;
+		} catch {
+			return false;
+		}
+	});
+
+	const requiredMissing = $derived.by(() => {
+		if (!tool || !argsValid) return false;
+		const schema = tool.inputSchema as { required?: string[] };
+		if (!schema.required?.length) return false;
+		try {
+			const parsed = JSON.parse(argsText) as Record<string, unknown>;
+			return schema.required.some((k) => !(k in parsed));
+		} catch {
+			return true;
+		}
+	});
+
+	const canCall = $derived(tool && argsValid && !requiredMissing && !loading);
+
+	const callTool = async () => {
+		if (!canCall) return;
+		response = undefined;
+		error = undefined;
+		loading = true;
+		try {
+			response = await toolsApi.callTool(
+				workspaceId,
+				toolId,
+				tool.name,
+				sessionId,
+				JSON.parse(argsText),
+			);
+		} catch (err) {
+			error =
+				err instanceof Error
+					? { type: 'Request Error', message: err.message }
+					: { type: 'Unknown Error', message: 'An error occurred' };
+		} finally {
+			loading = false;
+		}
+	};
+</script>
+
+<Resizable.PaneGroup class="max-h-96" direction="horizontal">
+	<!-- Schema -->
+	<Resizable.Pane class="bg-black/25" defaultSize={50}>
+		<pre class="h-full w-full overflow-auto p-4 font-mono text-xs text-white/75">{schemaText}</pre>
+	</Resizable.Pane>
+	<Resizable.Handle />
+	<!-- Arguments -->
+	<Resizable.Pane defaultSize={50}>
+		<div class="relative h-full w-full">
+			<textarea
+				class="h-full w-full resize-none p-4 font-mono text-sm outline-none"
+				spellcheck={false}
+				bind:value={argsText}
+			>
+			</textarea>
+			<div class="absolute bottom-0 flex w-full items-center justify-between p-2">
+				{#if argsText && !argsValid}
+					<p class="mb-2 text-xs text-red-300">Invalid JSON</p>
+				{:else if requiredMissing}
+					<p class="mb-2 text-xs text-amber-300">
+						Missing required fields: {((tool.inputSchema as { required?: string[] }).required ?? [])
+							.filter((k) => {
+								try {
+									return !(k in (JSON.parse(argsText) as Record<string, unknown>));
+								} catch {
+									return true;
+								}
+							})
+							.join(', ')}
+					</p>
+				{:else}
+					<div></div>
+				{/if}
+				<Button disabled={!canCall} size="icon-sm" variant="ghost" onclick={callTool}>
+					{#if loading}
+						<Spinner />
+					{/if}
+					<Send size={14} />
+				</Button>
+			</div>
+		</div>
+	</Resizable.Pane>
+</Resizable.PaneGroup>
+
+<!-- Result -->
+<TestResult class="border-t border-white/10 bg-black/25" {error} {loading} {response}>
+	{#snippet result(resp)}
+		{#if resp}
+			<div class="flex flex-col gap-2">
+				{#each resp.content as item, idx (idx)}
+					{@render contentItem(item)}
+				{/each}
+			</div>
+		{/if}
+	{/snippet}
+</TestResult>
+
+{#snippet contentItem(item: ToolCallContentItem)}
+	{#if item.type === 'text'}
+		<div class="rounded border border-white/6 bg-black/20 p-3">
+			<p class="mb-1 text-xs text-white/30">[text]</p>
+			<p class="text-sm whitespace-pre-wrap text-white/80">{item.text}</p>
+		</div>
+	{:else if item.type === 'image'}
+		<div class="rounded border border-white/6 bg-black/20 p-3">
+			<p class="mb-2 text-xs text-white/30">[image]</p>
+			<img
+				class="max-w-full rounded"
+				alt="Tool result"
+				src="data:{item.mimeType};base64,{item.data}"
+			/>
+			<p class="mt-1 text-xs text-white/30">{item.mimeType}</p>
+		</div>
+	{:else if item.type === 'resource'}
+		<div class="rounded border border-white/6 bg-black/20 p-3">
+			<p class="mb-1 text-xs text-white/30">[resource]</p>
+			<p class="mb-1 font-mono text-xs text-primary">{item.resource.uri}</p>
+			{#if item.resource.text}
+				<p class="text-sm whitespace-pre-wrap text-white/70">{item.resource.text}</p>
+			{:else if item.resource.mimeType}
+				<p class="text-xs text-white/40">binary resource · {item.resource.mimeType}</p>
+			{/if}
+		</div>
+	{/if}
+{/snippet}

+ 4 - 0
src/lib/components/tools/testing/index.ts

@@ -0,0 +1,4 @@
+import CallTest from './call.svelte';
+import MCPTest from './mcp.svelte';
+
+export { CallTest, MCPTest };

+ 141 - 0
src/lib/components/tools/testing/mcp.svelte

@@ -0,0 +1,141 @@
+<script lang="ts">
+	import { ChevronDown, ChevronUp, FileBraces, Plus } from '@lucide/svelte';
+	import { slide } from 'svelte/transition';
+
+	import type { GetToolListResponse, MCPTool } from '$lib/api/tools';
+	import * as toolsApi from '$lib/api/tools';
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { TestResult } from '$lib/components/common/test-result';
+	import { Button } from '$lib/components/controls/button';
+	import * as Select from '$lib/components/controls/select';
+
+	import CallTest from './call.svelte';
+
+	let {
+		workspaceId,
+		toolId,
+	}: {
+		workspaceId: string;
+		toolId: string;
+	} = $props();
+
+	let response = $state<GetToolListResponse | undefined>(undefined);
+	let loading = $state(false);
+	let error = $state<{ type: string; message: string } | undefined>(undefined);
+	let toolListShown = $state(true);
+	let selectedTool = $state<MCPTool | undefined>(undefined);
+	let sessionId = $state('');
+	const discoveredTools = $derived<MCPTool[]>(response?.response?.tools ?? []);
+
+	$effect(() => {
+		if (toolId) {
+			response = undefined;
+			error = undefined;
+			sessionId = '';
+		}
+	});
+
+	const getToolList = async () => {
+		if (loading) return;
+		response = undefined;
+		error = undefined;
+		sessionId = '';
+		loading = true;
+		try {
+			response = await toolsApi.getToolList(workspaceId, toolId);
+			sessionId = response.headers?.['mcp-session-id'] ?? '';
+		} catch (err) {
+			error =
+				err instanceof Error
+					? { type: 'Request Error', message: err.message }
+					: { type: 'Unknown Error', message: 'An error occurred' };
+		} finally {
+			loading = false;
+		}
+	};
+
+	const toggleToolList = () => {
+		toolListShown = !toolListShown;
+	};
+
+	const onSelectTool = (tool: MCPTool) => {
+		toolListShown = false;
+		selectedTool = tool;
+	};
+</script>
+
+<GlassPane class=" overflow-hidden bg-black/25">
+	<div class="bg-white/5 p-4">
+		<div class="flex items-center justify-between">
+			<p class="text-lg font-thin">MCP Tools{response ? ` (${discoveredTools.length})` : ''}</p>
+			<div class="flex items-center gap-2">
+				{#if discoveredTools.length > 0}
+					<Button title="Toggle MCP tool list" variant="ghost" onclick={() => toggleToolList()}>
+						{#if toolListShown}
+							<ChevronUp size={14} />
+						{:else}
+							<ChevronDown size={14} />
+						{/if}
+					</Button>
+				{/if}
+				<Button title="List MCP tools" onclick={() => getToolList()}>List Tools</Button>
+			</div>
+		</div>
+	</div>
+
+	<!-- Result area -->
+	<TestResult {error} {loading} {response}>
+		{#snippet result(resp)}
+			{#if resp}
+				{#if toolListShown}
+					<div class="flex flex-col divide-y divide-white/5" transition:slide>
+						{#each discoveredTools as mcpTool (mcpTool.name)}
+							<div class="p-2">
+								<div class="flex items-center">
+									<div class="mr-2 shrink-0 font-mono text-sm text-primary">{mcpTool.name}</div>
+									<Button size="icon-xs" variant="ghost" onclick={() => onSelectTool(mcpTool)}>
+										<FileBraces class="opacity-50" size={12} />
+									</Button>
+									<Button size="icon-xs" variant="ghost" onclick={() => onSelectTool(mcpTool)}>
+										<Plus class="opacity-50" size={12} />
+									</Button>
+								</div>
+								{#if mcpTool.description}
+									<div class="text-xs text-white/50">{mcpTool.description}</div>
+								{/if}
+							</div>
+						{/each}
+					</div>
+				{/if}
+			{/if}
+		{/snippet}
+	</TestResult>
+
+	{#if discoveredTools.length > 0}
+		<div class="bg-white/5 p-4">
+			<div class="flex items-center justify-between">
+				<p class="shrink-0 text-lg font-thin">Tool Call</p>
+				<Select.Root
+					type="single"
+					bind:value={
+						() => selectedTool?.name,
+						(name) => (selectedTool = discoveredTools.find((t) => t.name === name))
+					}
+				>
+					<Select.Trigger class="w-64">
+						<span class="truncate">{selectedTool?.name}</span>
+					</Select.Trigger>
+					<Select.Content>
+						{#each discoveredTools as t (t.name)}
+							<Select.Item label={t.name} value={t.name} />
+						{/each}
+					</Select.Content>
+				</Select.Root>
+			</div>
+		</div>
+
+		{#if selectedTool}
+			<CallTest {sessionId} tool={selectedTool} {toolId} {workspaceId} />
+		{/if}
+	{/if}
+</GlassPane>

+ 70 - 0
src/routes/api/workspaces/[workspaceId]/tools/[id]/test/+server.ts

@@ -0,0 +1,70 @@
+import { json } from '@sveltejs/kit';
+
+import {
+	type GetToolListResponse,
+	type ToolCallRequest,
+	type ToolCallResponse,
+} from '$lib/api/tools';
+import { NotFoundError, server, ValidationError } from '$lib/server';
+
+import type { RequestHandler } from './$types';
+
+const GET: RequestHandler = async ({ locals, params }) => {
+	const workspaceId = locals.workspaceId!;
+	const tools = await server.catalog.getWorkspaceCatalog(workspaceId).tools.list();
+	const tool = tools?.find((t) => t.id === params.id);
+	if (!tool) {
+		throw new NotFoundError('Tool not found');
+	}
+	const beginMs = Date.now();
+	const resp = await server.gateway.fetch(`/mcp-rest/tools/list?server_id=${tool.name}`);
+	const latencyMs = Date.now() - beginMs;
+	const testResp: GetToolListResponse = {
+		latencyMs,
+	};
+	if (!resp.ok) {
+		testResp.error = { type: resp.statusText, message: await resp.text() };
+	}
+	const data = await resp.json();
+	testResp.response = {
+		tools: data.tools.map((t: any) => ({
+			name: t.name,
+			description: t.description,
+			inputSchema: t.inputSchema,
+		})),
+	};
+	return json(testResp);
+};
+
+const POST: RequestHandler = async ({ locals, params, request }) => {
+	const workspaceId = locals.workspaceId!;
+	const tools = await server.catalog.getWorkspaceCatalog(workspaceId)?.tools.list();
+	const tool = tools?.find((t) => t.id === params.id);
+	if (!tool) {
+		throw new NotFoundError('Tool not found');
+	}
+	if (tool.mode === 'external') {
+		throw new ValidationError('stdio tools cannot be tested via HTTP');
+	}
+	const input: ToolCallRequest = await request.json();
+	const beginMs = Date.now();
+	const resp = await server.gateway.fetch(`/mcp-rest/tools/call?server_id=${tool.name}`, {
+		method: 'POST',
+		body: JSON.stringify({
+			server_id: tool.name,
+			name: input.tool,
+			arguments: input.arguments,
+		}),
+	});
+	const latencyMs = Date.now() - beginMs;
+	const testResp: ToolCallResponse = {
+		latencyMs,
+	};
+	if (!resp.ok) {
+		testResp.error = { type: resp.statusText, message: await resp.text() };
+	}
+	const data = await resp.json();
+	return json(testResp);
+};
+
+export { GET, POST };

+ 13 - 0
src/routes/utilities/testing/tool/+page.server.ts

@@ -0,0 +1,13 @@
+import * as toolsApi from '$lib/api/tools';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, url, locals }) => {
+	const { workspaceId } = locals;
+	if (!workspaceId) return { tools: [], selectedToolId: null };
+	const tools = await toolsApi.list(workspaceId, fetch);
+	const selectedToolId = url.searchParams.get('tool');
+	return { tools, selectedToolId };
+};
+
+export { load };

+ 77 - 0
src/routes/utilities/testing/tool/+page.svelte

@@ -0,0 +1,77 @@
+<script lang="ts">
+	import { Wrench } from '@lucide/svelte';
+
+	import { resolve } from '$app/paths';
+
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Icon } from '$lib/components/common/icon';
+	import { Button } from '$lib/components/controls/button';
+	import { ToolSelectDialog } from '$lib/components/tools/select';
+	import { MCPTest } from '$lib/components/tools/testing';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import type { Tool } from '$lib/types/entities';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Tools',
+			pages: [{ name: 'Calling', href: resolve('/utilities/testing/tool') }],
+		});
+	});
+
+	let toolSelectionShown = $state(false);
+	let selectedTool = $state<Tool | undefined>(undefined);
+
+	$effect.root(() => {
+		if (data.selectedToolId) {
+			const found = data.tools.find((t) => t.id === data.selectedToolId);
+			if (found) selectedTool = found;
+		}
+	});
+
+	const onselect = (tools: Tool[]): Tool[] => {
+		selectedTool = tools[0];
+		toolSelectionShown = false;
+		return [];
+	};
+</script>
+
+<svelte:head>
+	<title>Tool Calling - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-4xl p-6">
+	<div class="mb-4 flex items-center gap-2 text-xl font-semibold text-white/90">
+		<Wrench fill="white" opacity={0.5} size={18} strokeWidth={1} />
+		Tool Calling
+	</div>
+	<div class="mb-4">
+		<Button size="sm" variant="outline" onclick={() => (toolSelectionShown = true)}>
+			{#if selectedTool}
+				<Icon icon={selectedTool.icon} size={14} />
+				<div>{selectedTool.name}</div>
+			{:else}
+				Select tool...
+			{/if}
+		</Button>
+	</div>
+
+	{#if !selectedTool}
+		<GlassPane class="flex items-center justify-center bg-black/50 p-16">
+			<p class="text-sm text-white/30">Select a tool above to begin testing.</p>
+		</GlassPane>
+	{:else}
+		<MCPTest toolId={selectedTool.id} workspaceId={data.workspaceId!} />
+	{/if}
+</div>
+
+<ToolSelectDialog
+	multiple={false}
+	workspaceId={data.workspaceId!}
+	bind:open={toolSelectionShown}
+	{onselect}
+/>