File size: 3,355 Bytes
6c30253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
const MAX_RESULT_CHARACTERS = 4000;
const TOOL_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;

let client = null;

export async function connectMcpServer(rawUrl) {
  const url = validateServerUrl(rawUrl);
  await disconnectMcpServer();
  const [{ Client }, { StreamableHTTPClientTransport }] = await Promise.all([
    import('@modelcontextprotocol/sdk/client/index.js'),
    import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
  ]);
  const nextClient = new Client({ name: 'lfm-webgpu', version: '1.0.0' }, { capabilities: {} });
  try {
    await nextClient.connect(new StreamableHTTPClientTransport(url, { fetch: postOnlyFetch }));
    const response = await nextClient.listTools();
    client = nextClient;
    return normalizeMcpTools(response.tools);
  } catch (error) {
    await nextClient.close().catch(() => {});
    throw error;
  }
}

export async function disconnectMcpServer() {
  const active = client;
  client = null;
  if (active) await active.close().catch(() => {});
}

export async function callMcpTool(name, args, signal) {
  if (!client) throw new Error('The MCP server is not connected.');
  if (signal?.aborted) throw new DOMException('Generation stopped.', 'AbortError');
  const response = await client.callTool({ name, arguments: args });
  if (signal?.aborted) throw new DOMException('Generation stopped.', 'AbortError');
  if (response.isError) throw new Error(errorMessage(response.content));
  return compactMcpResult(response);
}

export function compactMcpResult(response) {
  const result = response.structuredContent ?? { content: response.content || [] };
  const serialized = JSON.stringify(result);
  if (serialized.length <= MAX_RESULT_CHARACTERS) return result;
  const text = (response.content || []).filter(item => item.type === 'text').map(item => item.text).join('\n');
  return {
    truncated: true,
    content: [{ type: 'text', text: (text || serialized).slice(0, MAX_RESULT_CHARACTERS) }],
  };
}

export function normalizeMcpTools(tools = []) {
  return (Array.isArray(tools) ? tools : []).flatMap(normalizeDiscoveredTool);
}

function normalizeDiscoveredTool(tool) {
  const name = String(tool?.name || '');
  const parameters = tool?.inputSchema;
  if (!TOOL_NAME.test(name) || !parameters || parameters.type !== 'object' || !parameters.properties || Array.isArray(parameters.properties)) return [];
  return [{
    id: `mcp:${name}`,
    name,
    description: String(tool.description || `MCP tool: ${name}`),
    parameters: structuredClone(parameters),
    source: 'mcp',
    enabled: false,
  }];
}

function validateServerUrl(rawUrl) {
  let url;
  try { url = new URL(String(rawUrl || '').trim()); } catch { throw new Error('Enter a valid MCP server URL.'); }
  const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
  if (url.protocol !== 'https:' && !localHttp) throw new Error('MCP servers must use HTTPS, except on localhost.');
  return url;
}

function errorMessage(content) {
  const text = (content || []).filter(item => item.type === 'text').map(item => item.text).join('\n').trim();
  return text || 'The MCP tool returned an error.';
}

function postOnlyFetch(input, init = {}) {
  if (String(init.method || 'GET').toUpperCase() === 'GET') return Promise.resolve(new Response(null, { status: 405 }));
  return fetch(input, init);
}