|
|
import { Client } from "@modelcontextprotocol/sdk/client"; |
|
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
|
|
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
|
|
import type { McpServerConfig } from "./httpClient"; |
|
|
|
|
|
|
|
|
export type OpenAiTool = { |
|
|
type: "function"; |
|
|
function: { name: string; description?: string; parameters?: Record<string, unknown> }; |
|
|
}; |
|
|
|
|
|
export interface McpToolMapping { |
|
|
fnName: string; |
|
|
server: string; |
|
|
tool: string; |
|
|
} |
|
|
|
|
|
interface CacheEntry { |
|
|
fetchedAt: number; |
|
|
ttlMs: number; |
|
|
tools: OpenAiTool[]; |
|
|
mapping: Record<string, McpToolMapping>; |
|
|
} |
|
|
|
|
|
const DEFAULT_TTL_MS = 60_000; |
|
|
const cache = new Map<string, CacheEntry>(); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeName(name: string) { |
|
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64); |
|
|
} |
|
|
|
|
|
function buildCacheKey(servers: McpServerConfig[]): string { |
|
|
const normalized = servers |
|
|
.map((server) => ({ |
|
|
name: server.name, |
|
|
url: server.url, |
|
|
headers: server.headers |
|
|
? Object.entries(server.headers) |
|
|
.sort(([a], [b]) => a.localeCompare(b)) |
|
|
.map(([key, value]) => [key, value]) |
|
|
: [], |
|
|
})) |
|
|
.sort((a, b) => { |
|
|
const byName = a.name.localeCompare(b.name); |
|
|
if (byName !== 0) return byName; |
|
|
return a.url.localeCompare(b.url); |
|
|
}); |
|
|
|
|
|
return JSON.stringify(normalized); |
|
|
} |
|
|
|
|
|
type ListedTool = { |
|
|
name?: string; |
|
|
inputSchema?: Record<string, unknown>; |
|
|
description?: string; |
|
|
annotations?: { title?: string }; |
|
|
}; |
|
|
|
|
|
async function listServerTools( |
|
|
server: McpServerConfig, |
|
|
opts: { signal?: AbortSignal } = {} |
|
|
): Promise<ListedTool[]> { |
|
|
const url = new URL(server.url); |
|
|
const client = new Client({ name: "chat-ui-mcp", version: "0.1.0" }); |
|
|
try { |
|
|
try { |
|
|
const transport = new StreamableHTTPClientTransport(url, { |
|
|
requestInit: { headers: server.headers, signal: opts.signal }, |
|
|
}); |
|
|
await client.connect(transport); |
|
|
} catch { |
|
|
const transport = new SSEClientTransport(url, { |
|
|
requestInit: { headers: server.headers, signal: opts.signal }, |
|
|
}); |
|
|
await client.connect(transport); |
|
|
} |
|
|
|
|
|
const response = await client.listTools({}); |
|
|
const tools = Array.isArray(response?.tools) ? (response.tools as ListedTool[]) : []; |
|
|
try { |
|
|
console.debug( |
|
|
{ |
|
|
server: server.name, |
|
|
url: server.url, |
|
|
count: tools.length, |
|
|
toolNames: tools.map((t) => t?.name).filter(Boolean), |
|
|
}, |
|
|
"[mcp] listed tools from server" |
|
|
); |
|
|
} catch {} |
|
|
return tools; |
|
|
} finally { |
|
|
try { |
|
|
await client.close?.(); |
|
|
} catch { |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
export async function getOpenAiToolsForMcp( |
|
|
servers: McpServerConfig[], |
|
|
{ ttlMs = DEFAULT_TTL_MS, signal }: { ttlMs?: number; signal?: AbortSignal } = {} |
|
|
): Promise<{ tools: OpenAiTool[]; mapping: Record<string, McpToolMapping> }> { |
|
|
const now = Date.now(); |
|
|
const cacheKey = buildCacheKey(servers); |
|
|
const cached = cache.get(cacheKey); |
|
|
if (cached && now - cached.fetchedAt < cached.ttlMs) { |
|
|
return { tools: cached.tools, mapping: cached.mapping }; |
|
|
} |
|
|
|
|
|
const tools: OpenAiTool[] = []; |
|
|
const mapping: Record<string, McpToolMapping> = {}; |
|
|
|
|
|
const seenNames = new Set<string>(); |
|
|
|
|
|
const pushToolDefinition = ( |
|
|
name: string, |
|
|
description: string | undefined, |
|
|
parameters: Record<string, unknown> | undefined |
|
|
) => { |
|
|
if (seenNames.has(name)) return; |
|
|
tools.push({ |
|
|
type: "function", |
|
|
function: { |
|
|
name, |
|
|
description, |
|
|
parameters, |
|
|
}, |
|
|
}); |
|
|
seenNames.add(name); |
|
|
}; |
|
|
|
|
|
|
|
|
const tasks = servers.map((server) => listServerTools(server, { signal })); |
|
|
const results = await Promise.allSettled(tasks); |
|
|
|
|
|
for (let i = 0; i < results.length; i++) { |
|
|
const server = servers[i]; |
|
|
const r = results[i]; |
|
|
if (r.status === "fulfilled") { |
|
|
const serverTools = r.value; |
|
|
for (const tool of serverTools) { |
|
|
if (typeof tool.name !== "string" || tool.name.trim().length === 0) { |
|
|
continue; |
|
|
} |
|
|
|
|
|
const parameters = |
|
|
tool.inputSchema && typeof tool.inputSchema === "object" ? tool.inputSchema : undefined; |
|
|
const description = tool.description ?? tool.annotations?.title; |
|
|
const toolName = tool.name; |
|
|
|
|
|
|
|
|
|
|
|
let plainName = sanitizeName(toolName); |
|
|
if (plainName in mapping) { |
|
|
const suffix = sanitizeName(server.name); |
|
|
const candidate = `${plainName}_${suffix}`.slice(0, 64); |
|
|
if (!(candidate in mapping)) { |
|
|
plainName = candidate; |
|
|
} else { |
|
|
let i = 2; |
|
|
let next = `${candidate}_${i}`; |
|
|
while (i < 10 && next in mapping) { |
|
|
i += 1; |
|
|
next = `${candidate}_${i}`; |
|
|
} |
|
|
plainName = next.slice(0, 64); |
|
|
} |
|
|
} |
|
|
|
|
|
pushToolDefinition(plainName, description, parameters); |
|
|
mapping[plainName] = { |
|
|
fnName: plainName, |
|
|
server: server.name, |
|
|
tool: toolName, |
|
|
}; |
|
|
} |
|
|
} else { |
|
|
|
|
|
continue; |
|
|
} |
|
|
} |
|
|
|
|
|
cache.set(cacheKey, { fetchedAt: now, ttlMs, tools, mapping }); |
|
|
return { tools, mapping }; |
|
|
} |
|
|
|
|
|
export function resetMcpToolsCache() { |
|
|
cache.clear(); |
|
|
} |
|
|
|