char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Gautam0507ui: read structuredContent from MCP tool result when content is empty (#26691)0d9ceae1e

char/aliceai-t5-moe
10.2 KiB349 linesraw
1import { Client } from '@modelcontextprotocol/sdk/client';
2import { CORS_PROXY } from '$lib/constants';
3import { MCPConnectionPhase, MCPTransportType } from '$lib/enums';
4import { MCPService } from '$lib/services/mcp.service';
5import type { MCPConnection, MCPConnectionLog, MCPServerConfig } from '$lib/types';
6import { afterEach, describe, expect, it, vi } from 'vitest';
7
8type DiagnosticFetchFactory = (
9	serverName: string,
10	config: MCPServerConfig,
11	baseInit: RequestInit,
12	targetUrl: URL,
13	useProxy: boolean,
14	onLog?: (log: MCPConnectionLog) => void
15) => { fetch: typeof fetch; disable: () => void };
16
17const createDiagnosticFetch = (
18	config: MCPServerConfig,
19	onLog?: (log: MCPConnectionLog) => void,
20	baseInit: RequestInit = {},
21	useProxy = false
22) =>
23	(
24		MCPService as unknown as { createDiagnosticFetch: DiagnosticFetchFactory }
25	).createDiagnosticFetch('test-server', config, baseInit, new URL(config.url), useProxy, onLog);
26
27describe('MCPService', () => {
28	afterEach(() => {
29		vi.restoreAllMocks();
30		vi.unstubAllGlobals();
31	});
32
33	it('stops transport phase logging after handshake diagnostics are disabled', async () => {
34		const logs: MCPConnectionLog[] = [];
35		const response = new Response('{}', {
36			headers: { 'content-type': 'application/json' },
37			status: 200
38		});
39
40		vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
41
42		const config: MCPServerConfig = {
43			transport: MCPTransportType.STREAMABLE_HTTP,
44			url: 'https://example.com/mcp'
45		};
46		const controller = createDiagnosticFetch(config, (log) => logs.push(log));
47
48		await controller.fetch(config.url, { body: '{}', method: 'POST' });
49		expect(logs).toHaveLength(2);
50		expect(logs.every((log) => log.message.includes('https://example.com/mcp'))).toBe(true);
51
52		controller.disable();
53		await controller.fetch(config.url, { body: '{}', method: 'POST' });
54
55		expect(logs).toHaveLength(2);
56	});
57
58	it('redacts all configured custom headers in diagnostic request logs', async () => {
59		const logs: MCPConnectionLog[] = [];
60		const response = new Response('{}', {
61			headers: { 'content-type': 'application/json' },
62			status: 200
63		});
64
65		vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
66
67		const config: MCPServerConfig = {
68			headers: {
69				'x-auth-token': 'secret-token',
70				'x-vendor-api-key': 'secret-key'
71			},
72			transport: MCPTransportType.STREAMABLE_HTTP,
73			url: 'https://example.com/mcp'
74		};
75		const controller = createDiagnosticFetch(config, (log) => logs.push(log), {
76			headers: config.headers
77		});
78
79		await controller.fetch(config.url, {
80			body: '{}',
81			headers: { 'content-type': 'application/json' },
82			method: 'POST'
83		});
84
85		expect(logs).toHaveLength(2);
86		expect(logs[0].details).toMatchObject({
87			request: {
88				headers: {
89					'content-type': 'application/json',
90					'x-auth-token': '[redacted]',
91					'x-vendor-api-key': '[redacted]'
92				}
93			}
94		});
95	});
96
97	it('wraps dynamic request headers when using the CORS proxy', async () => {
98		const logs: MCPConnectionLog[] = [];
99		const proxiedAuthToken = `${CORS_PROXY.HEADER_PREFIX}x-auth-token`;
100		const proxiedContentType = `${CORS_PROXY.HEADER_PREFIX}content-type`;
101		const proxiedSessionId = `${CORS_PROXY.HEADER_PREFIX}mcp-session-id`;
102		const response = new Response('{}', {
103			headers: { 'content-type': 'application/json' },
104			status: 200
105		});
106		const fetchMock = vi.fn().mockResolvedValue(response);
107
108		vi.stubGlobal('fetch', fetchMock);
109
110		const config: MCPServerConfig = {
111			transport: MCPTransportType.STREAMABLE_HTTP,
112			url: 'https://example.com/mcp',
113			useProxy: true
114		};
115		const controller = createDiagnosticFetch(
116			config,
117			(log) => logs.push(log),
118			{
119				headers: {
120					authorization: 'Bearer llama-server-key',
121					[proxiedAuthToken]: 'target-token'
122				}
123			},
124			true
125		);
126
127		await controller.fetch('http://localhost:8080/cors-proxy?url=https%3A%2F%2Fexample.com%2Fmcp', {
128			body: '{}',
129			headers: {
130				'content-type': 'application/json',
131				'mcp-session-id': 'session-request-12345'
132			},
133			method: 'POST'
134		});
135
136		const sentHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
137
138		expect(sentHeaders.get('authorization')).toBe('Bearer llama-server-key');
139		expect(sentHeaders.get(proxiedAuthToken)).toBe('target-token');
140		expect(sentHeaders.get(proxiedContentType)).toBe('application/json');
141		expect(sentHeaders.get(proxiedSessionId)).toBe('session-request-12345');
142		expect(sentHeaders.has('content-type')).toBe(false);
143		expect(sentHeaders.has('mcp-session-id')).toBe(false);
144		expect(logs[0].details).toMatchObject({
145			request: {
146				headers: {
147					authorization: '[redacted]',
148					[proxiedAuthToken]: '[redacted]',
149					[proxiedSessionId]: '....12345'
150				}
151			}
152		});
153	});
154
155	it('DELETE request with CORS proxy should return a fake 200 response', async () => {
156		const logs: MCPConnectionLog[] = [];
157		const fetchMock = vi.fn();
158
159		vi.stubGlobal('fetch', fetchMock);
160
161		const config: MCPServerConfig = {
162			transport: MCPTransportType.STREAMABLE_HTTP,
163			url: 'https://example.com/mcp',
164			useProxy: true
165		};
166		const controller = createDiagnosticFetch(config, (log) => logs.push(log), {}, true);
167		const response = await controller.fetch(
168			'http://localhost:8080/cors-proxy?url=https%3A%2F%2Fexample.com%2Fmcp',
169			{ method: 'DELETE' }
170		);
171
172		expect(fetchMock).not.toHaveBeenCalled();
173		expect(response.status).toBe(200);
174		expect(logs.at(-1)?.details).toMatchObject({
175			response: { isFake: true, status: 200 }
176		});
177	});
178
179	it('partially redacts mcp-session-id in diagnostic request and response logs', async () => {
180		const logs: MCPConnectionLog[] = [];
181		const response = new Response('{}', {
182			headers: {
183				'content-type': 'application/json',
184				'mcp-session-id': 'session-response-67890'
185			},
186			status: 200
187		});
188
189		vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
190
191		const config: MCPServerConfig = {
192			transport: MCPTransportType.STREAMABLE_HTTP,
193			url: 'https://example.com/mcp'
194		};
195		const controller = createDiagnosticFetch(config, (log) => logs.push(log));
196
197		await controller.fetch(config.url, {
198			body: '{}',
199			headers: {
200				'content-type': 'application/json',
201				'mcp-session-id': 'session-request-12345'
202			},
203			method: 'POST'
204		});
205
206		expect(logs).toHaveLength(2);
207		expect(logs[0].details).toMatchObject({
208			request: {
209				headers: {
210					'content-type': 'application/json',
211					'mcp-session-id': '....12345'
212				}
213			}
214		});
215		expect(logs[1].details).toMatchObject({
216			response: {
217				headers: {
218					'content-type': 'application/json',
219					'mcp-session-id': '....67890'
220				}
221			}
222		});
223	});
224
225	it('extracts JSON-RPC methods without logging the raw request body', async () => {
226		const logs: MCPConnectionLog[] = [];
227		const response = new Response('{}', {
228			headers: { 'content-type': 'application/json' },
229			status: 200
230		});
231
232		vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
233
234		const config: MCPServerConfig = {
235			transport: MCPTransportType.STREAMABLE_HTTP,
236			url: 'https://example.com/mcp'
237		};
238		const controller = createDiagnosticFetch(config, (log) => logs.push(log));
239
240		await controller.fetch(config.url, {
241			body: JSON.stringify([
242				{ id: 1, jsonrpc: '2.0', method: 'initialize' },
243				{ jsonrpc: '2.0', method: 'notifications/initialized' }
244			]),
245			method: 'POST'
246		});
247
248		expect(logs[0].details).toMatchObject({
249			request: {
250				body: {
251					kind: 'string',
252					size: expect.any(Number)
253				},
254				jsonRpcMethods: ['initialize', 'notifications/initialized'],
255				method: 'POST'
256			}
257		});
258	});
259
260	it('adds a CORS hint to Failed to fetch diagnostic log messages', async () => {
261		const logs: MCPConnectionLog[] = [];
262		const fetchError = new TypeError('Failed to fetch');
263
264		vi.stubGlobal('fetch', vi.fn().mockRejectedValue(fetchError));
265
266		const config: MCPServerConfig = {
267			transport: MCPTransportType.STREAMABLE_HTTP,
268			url: 'http://localhost:8000/mcp'
269		};
270		const controller = createDiagnosticFetch(config, (log) => logs.push(log));
271
272		await expect(controller.fetch(config.url, { body: '{}', method: 'POST' })).rejects.toThrow(
273			'Failed to fetch'
274		);
275
276		expect(logs).toHaveLength(2);
277		expect(logs[1].message).toBe(
278			'HTTP POST http://localhost:8000/mcp failed: Failed to fetch (check CORS?)'
279		);
280	});
281
282	it('detaches phase error logging after the initialize handshake completes', async () => {
283		const phaseLogs: Array<{ phase: MCPConnectionPhase; log: MCPConnectionLog }> = [];
284		const stopPhaseLogging = vi.fn();
285
286		let emitClientError: ((error: Error) => void) | undefined;
287
288		vi.spyOn(MCPService, 'createTransport').mockReturnValue({
289			stopPhaseLogging,
290			transport: {} as never,
291			type: MCPTransportType.WEBSOCKET
292		});
293		vi.spyOn(MCPService, 'listTools').mockResolvedValue([]);
294		vi.spyOn(Client.prototype, 'getServerVersion').mockReturnValue(undefined);
295		vi.spyOn(Client.prototype, 'getServerCapabilities').mockReturnValue(undefined);
296		vi.spyOn(Client.prototype, 'getInstructions').mockReturnValue(undefined);
297		vi.spyOn(Client.prototype, 'connect').mockImplementation(async function (this: Client) {
298			emitClientError = (error: Error) => this.onerror?.(error);
299			this.onerror?.(new Error('handshake protocol error'));
300		});
301
302		await MCPService.connect(
303			'test-server',
304			{
305				transport: MCPTransportType.WEBSOCKET,
306				url: 'ws://example.com/mcp'
307			},
308			undefined,
309			undefined,
310			(phase, log) => phaseLogs.push({ log, phase })
311		);
312
313		expect(stopPhaseLogging).toHaveBeenCalledTimes(1);
314		expect(
315			phaseLogs.filter(
316				({ log, phase }) =>
317					phase === MCPConnectionPhase.ERROR &&
318					log.message === 'Protocol error: handshake protocol error'
319			)
320		).toHaveLength(1);
321
322		emitClientError?.(new Error('runtime protocol error'));
323
324		expect(
325			phaseLogs.filter(
326				({ log, phase }) =>
327					phase === MCPConnectionPhase.ERROR &&
328					log.message === 'Protocol error: runtime protocol error'
329			)
330		).toHaveLength(0);
331	});
332
333	it('falls back to structuredContent when content array is empty', async () => {
334		const connection = {
335			client: {
336				callTool: vi.fn().mockResolvedValue({
337					content: [],
338					structuredContent: { accounts: [{ id: 1 }], total: 1 }
339				})
340			},
341			requestTimeoutMs: 9000,
342			serverName: 'test-server'
343		} as unknown as MCPConnection;
344		const result = await MCPService.callTool(connection, { arguments: {}, name: 'tool' });
345
346		expect(result.isError).toBe(false);
347		expect(result.content).toBe('{"accounts":[{"id":1}],"total":1}');
348	});
349});