Skip to content

Commit 580dd76

Browse files
committed
Improve tool update display and MCP client fallback
Enhanced ToolUpdate.svelte to better format and display tool parameters, outputs, and error messages. Refactored httpClient.ts to robustly fallback from streamable to SSE transport, improving error handling and connection management for MCP tool calls.
1 parent e88b011 commit 580dd76

File tree

2 files changed

+75
-75
lines changed

2 files changed

+75
-75
lines changed

src/lib/components/chat/ToolUpdate.svelte

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@
3030
let animation: Animation | undefined = $state();
3131
let showingLoadingBar = $state(false);
3232
33+
const formatValue = (value: unknown): string => {
34+
if (value == null) return "";
35+
if (typeof value === "object") {
36+
try {
37+
return JSON.stringify(value, null, 2);
38+
} catch {
39+
return String(value);
40+
}
41+
}
42+
return String(value);
43+
};
44+
3345
$effect(() => {
3446
if (!toolError && !toolDone && loading && loadingBarEl && eta) {
3547
loadingBarEl.classList.remove("hidden");
@@ -50,10 +62,11 @@
5062
showingLoadingBar = false;
5163
loadingBarEl.classList.remove("hidden");
5264
animation?.cancel();
53-
animation = loadingBarEl.animate(
54-
[{ width: loadingBarEl.style.width }, { width: "calc(100%+1rem)" }],
55-
{ duration: 300, fill: "forwards" }
56-
);
65+
const fromWidth = getComputedStyle(loadingBarEl).width;
66+
animation = loadingBarEl.animate([{ width: fromWidth }, { width: "calc(100%+1rem)" }], {
67+
duration: 300,
68+
fill: "forwards",
69+
});
5770
setTimeout(() => loadingBarEl?.classList.add("hidden"), 300);
5871
}
5972
});
@@ -111,10 +124,10 @@
111124
</div>
112125
<ul class="py-1 text-sm">
113126
{#each Object.entries(update.call.parameters ?? {}) as [key, value]}
114-
{#if value !== null}
127+
{#if value != null}
115128
<li>
116129
<span class="font-semibold">{key}</span>:
117-
<span>{value}</span>
130+
<span class="whitespace-pre-wrap">{formatValue(value)}</span>
118131
</li>
119132
{/if}
120133
{/each}
@@ -133,15 +146,23 @@
133146
<ul class="py-1 text-sm">
134147
{#each update.result.outputs as output}
135148
{#each Object.entries(output) as [key, value]}
136-
{#if value !== null}
149+
{#if value != null}
137150
<li>
138151
<span class="font-semibold">{key}</span>:
139-
<span>{value}</span>
152+
<span class="whitespace-pre-wrap">{formatValue(value)}</span>
140153
</li>
141154
{/if}
142155
{/each}
143156
{/each}
144157
</ul>
158+
{:else if isMessageToolResultUpdate(update) && update.result.status === ToolResultStatus.Error && update.result.display}
159+
<div class="mt-1 flex items-center gap-2 opacity-80">
160+
<h3 class="text-sm text-red-600 dark:text-red-400">Error</h3>
161+
<div class="h-px flex-1 bg-gradient-to-r from-red-500/20"></div>
162+
</div>
163+
<p class="whitespace-pre-wrap text-sm text-red-600 dark:text-red-400">
164+
{update.result.message}
165+
</p>
145166
{/if}
146167
{/each}
147168
</details>
@@ -152,6 +173,15 @@
152173
display: none;
153174
}
154175
176+
@keyframes loading {
177+
0% {
178+
stroke-dashoffset: 61.45;
179+
}
180+
100% {
181+
stroke-dashoffset: 0;
182+
}
183+
}
184+
155185
.loading-path {
156186
stroke-dasharray: 61.45;
157187
animation: loading 2s linear infinite;

src/lib/server/mcp/httpClient.ts

Lines changed: 37 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -24,92 +24,62 @@ export async function callMcpTool(
2424
args: unknown = {},
2525
{ timeoutMs = DEFAULT_TIMEOUT_MS }: { timeoutMs?: number } = {}
2626
): Promise<string> {
27-
const controller = new AbortController();
28-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
29-
30-
const client = new Client({ name: "chat-ui-mcp", version: "0.1.0" });
3127
const url = toUrl(server.url);
32-
3328
const normalizedArgs =
3429
typeof args === "object" && args !== null && !Array.isArray(args)
3530
? (args as Record<string, unknown>)
3631
: undefined;
3732

38-
async function connectStreamable() {
39-
const transport = new StreamableHTTPClientTransport(url, {
40-
requestInit: { headers: server.headers },
41-
});
33+
async function connect(kind: "streamable" | "sse", signal: AbortSignal, client: Client) {
34+
const requestInit: RequestInit = { headers: server.headers, signal };
35+
const transport =
36+
kind === "streamable"
37+
? new StreamableHTTPClientTransport(url, { requestInit })
38+
: new SSEClientTransport(url, { requestInit });
4239
await client.connect(transport);
4340
}
4441

45-
async function connectSse() {
46-
const transport = new SSEClientTransport(url, {
47-
requestInit: { headers: server.headers },
48-
});
49-
await client.connect(transport);
50-
}
42+
let lastError: unknown;
5143

52-
try {
53-
let usingSse = false;
54-
try {
55-
await connectStreamable();
56-
} catch {
57-
await connectSse();
58-
usingSse = true;
59-
}
44+
for (const kind of ["streamable", "sse"] as const) {
45+
const controller = new AbortController();
46+
const client = new Client({ name: "chat-ui-mcp", version: "0.1.0" });
47+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
6048

61-
const runTool = async () => client.callTool({ name: tool, arguments: normalizedArgs });
62-
type CallToolResult = Awaited<ReturnType<typeof runTool>>;
49+
try {
50+
await connect(kind, controller.signal, client);
51+
const response = await client.callTool({ name: tool, arguments: normalizedArgs });
52+
const parts = Array.isArray(response?.content) ? (response.content as Array<unknown>) : [];
53+
const textParts = parts
54+
.filter((part): part is { type: "text"; text: string } => {
55+
if (typeof part !== "object" || part === null) return false;
56+
const candidate = part as { type?: unknown; text?: unknown };
57+
return candidate.type === "text" && typeof candidate.text === "string";
58+
})
59+
.map((part) => part.text);
6360

64-
const raceWithTimeout = <T>(promise: Promise<T>) =>
65-
Promise.race([
66-
promise,
67-
new Promise<never>((_, reject) => {
68-
controller.signal.addEventListener(
69-
"abort",
70-
() => reject(new Error("MCP tool call timed out")),
71-
{
72-
once: true,
73-
}
74-
);
75-
}),
76-
]);
61+
if (textParts.length > 0) {
62+
return textParts.join("\n");
63+
}
7764

78-
let response: CallToolResult;
79-
try {
80-
response = await raceWithTimeout(runTool());
65+
return JSON.stringify(response);
8166
} catch (error) {
82-
if (usingSse) throw error;
67+
lastError = error;
68+
if (kind === "sse") {
69+
throw error;
70+
}
71+
} finally {
72+
clearTimeout(timeout);
73+
controller.abort();
8374
try {
8475
await client.close?.();
8576
} catch {
8677
// ignore close errors
8778
}
88-
await connectSse();
89-
usingSse = true;
90-
response = await raceWithTimeout(runTool());
91-
}
92-
93-
const parts = Array.isArray(response?.content) ? (response.content as Array<unknown>) : [];
94-
const textParts = parts
95-
.filter((part): part is { type: "text"; text: string } => {
96-
if (typeof part !== "object" || part === null) return false;
97-
const candidate = part as { type?: unknown; text?: unknown };
98-
return candidate.type === "text" && typeof candidate.text === "string";
99-
})
100-
.map((part) => part.text);
101-
102-
if (textParts.length > 0) {
103-
return textParts.join("\n");
104-
}
105-
106-
return JSON.stringify(response);
107-
} finally {
108-
clearTimeout(timeout);
109-
try {
110-
await client.close?.();
111-
} catch {
112-
// ignore close errors
11379
}
11480
}
81+
82+
throw lastError instanceof Error
83+
? lastError
84+
: new Error(String(lastError ?? "MCP tool call failed"));
11585
}

0 commit comments

Comments
 (0)