345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
|
|
import { Client, type ClientOptions } from '@modelcontextprotocol/sdk/client/index.js';
|
||
|
|
import {
|
||
|
|
StdioClientTransport,
|
||
|
|
type StdioServerParameters,
|
||
|
|
} from '@modelcontextprotocol/sdk/client/stdio.js';
|
||
|
|
import {
|
||
|
|
StreamableHTTPClientTransport,
|
||
|
|
type StreamableHTTPClientTransportOptions,
|
||
|
|
} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||
|
|
import type { RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||
|
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||
|
|
import { jsonSchema, tool } from '@push.rocks/smartai';
|
||
|
|
import type { ToolSet } from '@push.rocks/smartai';
|
||
|
|
|
||
|
|
export type TMcpListToolsResult = Awaited<ReturnType<Client['listTools']>>;
|
||
|
|
export type TMcpToolMetadata = TMcpListToolsResult['tools'][number];
|
||
|
|
export type TMcpCallToolResult = Awaited<ReturnType<Client['callTool']>>;
|
||
|
|
|
||
|
|
export interface IMcpClientLike {
|
||
|
|
listTools(params?: { cursor?: string }, options?: RequestOptions): Promise<TMcpListToolsResult>;
|
||
|
|
callTool(
|
||
|
|
params: { name: string; arguments?: Record<string, unknown> },
|
||
|
|
resultSchema?: unknown,
|
||
|
|
options?: RequestOptions,
|
||
|
|
): Promise<TMcpCallToolResult>;
|
||
|
|
close?(): Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IMcpServerClientOptions {
|
||
|
|
clientName?: string;
|
||
|
|
clientVersion?: string;
|
||
|
|
clientOptions?: ClientOptions;
|
||
|
|
requestOptions?: RequestOptions;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IMcpStdioServerConfig extends StdioServerParameters, IMcpServerClientOptions {
|
||
|
|
type: 'stdio';
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IMcpStreamableHttpServerConfig extends IMcpServerClientOptions {
|
||
|
|
type: 'streamableHttp' | 'http';
|
||
|
|
url: string | URL;
|
||
|
|
transportOptions?: StreamableHTTPClientTransportOptions;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IMcpTransportServerConfig extends IMcpServerClientOptions {
|
||
|
|
type: 'transport';
|
||
|
|
transport: Transport;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IMcpClientServerConfig {
|
||
|
|
type: 'client';
|
||
|
|
client: IMcpClientLike;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type TMcpServerConfig =
|
||
|
|
| IMcpStdioServerConfig
|
||
|
|
| IMcpStreamableHttpServerConfig
|
||
|
|
| IMcpTransportServerConfig
|
||
|
|
| IMcpClientServerConfig;
|
||
|
|
|
||
|
|
export interface IMcpToolFormatContext {
|
||
|
|
serverName: string;
|
||
|
|
toolName: string;
|
||
|
|
exposedToolName: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ICreateMcpToolsOptions {
|
||
|
|
servers: Record<string, TMcpServerConfig>;
|
||
|
|
clientName?: string;
|
||
|
|
clientVersion?: string;
|
||
|
|
clientOptions?: ClientOptions;
|
||
|
|
requestOptions?: RequestOptions;
|
||
|
|
listToolsRequestOptions?: RequestOptions;
|
||
|
|
callToolRequestOptions?: RequestOptions;
|
||
|
|
prefixToolNames?: boolean;
|
||
|
|
formatResult?: (result: TMcpCallToolResult, context: IMcpToolFormatContext) => unknown;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IMcpConnectedServer {
|
||
|
|
name: string;
|
||
|
|
client: IMcpClientLike;
|
||
|
|
tools: TMcpToolMetadata[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IMcpToolNameMapping {
|
||
|
|
serverName: string;
|
||
|
|
toolName: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ICreateMcpToolsResult {
|
||
|
|
tools: ToolSet;
|
||
|
|
servers: IMcpConnectedServer[];
|
||
|
|
toolNameMap: Record<string, IMcpToolNameMapping>;
|
||
|
|
close: () => Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const createMcpTools = async (
|
||
|
|
options: ICreateMcpToolsOptions,
|
||
|
|
): Promise<ICreateMcpToolsResult> => {
|
||
|
|
const serverEntries = Object.entries(options.servers);
|
||
|
|
if (serverEntries.length === 0) {
|
||
|
|
throw new Error('createMcpTools requires at least one MCP server.');
|
||
|
|
}
|
||
|
|
|
||
|
|
const connectedServers: IMcpConnectedServer[] = [];
|
||
|
|
try {
|
||
|
|
for (const [serverName, config] of serverEntries) {
|
||
|
|
const client = await connectMcpServer(serverName, config, options);
|
||
|
|
const tools = await listAllMcpTools(
|
||
|
|
client,
|
||
|
|
options.listToolsRequestOptions ?? getServerRequestOptions(config) ?? options.requestOptions,
|
||
|
|
);
|
||
|
|
connectedServers.push({ name: serverName, client, tools });
|
||
|
|
}
|
||
|
|
|
||
|
|
const exposedTools: ToolSet = {};
|
||
|
|
const toolNameMap: Record<string, IMcpToolNameMapping> = {};
|
||
|
|
const usedToolNames = new Set<string>();
|
||
|
|
const formatResult = options.formatResult ?? formatMcpToolResult;
|
||
|
|
const shouldPrefixToolNames = options.prefixToolNames ?? connectedServers.length > 1;
|
||
|
|
|
||
|
|
for (const server of connectedServers) {
|
||
|
|
for (const mcpTool of server.tools) {
|
||
|
|
const baseToolName = createExposedToolName(server.name, mcpTool.name, shouldPrefixToolNames);
|
||
|
|
const exposedToolName = createUniqueToolName(baseToolName, usedToolNames);
|
||
|
|
usedToolNames.add(exposedToolName);
|
||
|
|
toolNameMap[exposedToolName] = {
|
||
|
|
serverName: server.name,
|
||
|
|
toolName: mcpTool.name,
|
||
|
|
};
|
||
|
|
|
||
|
|
exposedTools[exposedToolName] = tool({
|
||
|
|
description: mcpTool.description ?? `MCP tool ${mcpTool.name} from ${server.name}`,
|
||
|
|
inputSchema: jsonSchema(mcpTool.inputSchema as never),
|
||
|
|
execute: async (input: unknown) => {
|
||
|
|
const result = await server.client.callTool(
|
||
|
|
{
|
||
|
|
name: mcpTool.name,
|
||
|
|
arguments: normalizeMcpArguments(input),
|
||
|
|
},
|
||
|
|
undefined,
|
||
|
|
options.callToolRequestOptions ?? options.requestOptions,
|
||
|
|
);
|
||
|
|
return formatResult(result, {
|
||
|
|
serverName: server.name,
|
||
|
|
toolName: mcpTool.name,
|
||
|
|
exposedToolName,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
tools: exposedTools,
|
||
|
|
servers: connectedServers,
|
||
|
|
toolNameMap,
|
||
|
|
close: async () => closeMcpClients(connectedServers),
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
await closeMcpClients(connectedServers);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
export const sanitizeMcpToolName = (name: string, fallback = 'mcp_tool'): string => {
|
||
|
|
const sanitized = name
|
||
|
|
.trim()
|
||
|
|
.replace(/[^a-zA-Z0-9_-]+/g, '_')
|
||
|
|
.replace(/^_+|_+$/g, '');
|
||
|
|
const safeName = sanitized || fallback;
|
||
|
|
return /^[a-zA-Z_]/.test(safeName) ? safeName : `_${safeName}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
export const formatMcpToolResult = (result: TMcpCallToolResult): unknown => {
|
||
|
|
const resultRecord = result as Record<string, unknown>;
|
||
|
|
if (Object.prototype.hasOwnProperty.call(resultRecord, 'toolResult')) {
|
||
|
|
return resultRecord.toolResult;
|
||
|
|
}
|
||
|
|
|
||
|
|
const content = Array.isArray(resultRecord.content)
|
||
|
|
? resultRecord.content.map(formatMcpContentPart).filter(Boolean).join('\n\n')
|
||
|
|
: '';
|
||
|
|
const formattedContent = resultRecord.isError && content
|
||
|
|
? `MCP tool returned an error:\n${content}`
|
||
|
|
: content;
|
||
|
|
const structuredContent = resultRecord.structuredContent;
|
||
|
|
|
||
|
|
if (structuredContent !== undefined && formattedContent) {
|
||
|
|
return `${formattedContent}\n\nStructured content:\n${stringifyJson(structuredContent)}`;
|
||
|
|
}
|
||
|
|
if (structuredContent !== undefined) {
|
||
|
|
return structuredContent;
|
||
|
|
}
|
||
|
|
if (formattedContent) {
|
||
|
|
return formattedContent;
|
||
|
|
}
|
||
|
|
return stringifyJson(result);
|
||
|
|
};
|
||
|
|
|
||
|
|
const connectMcpServer = async (
|
||
|
|
serverName: string,
|
||
|
|
config: TMcpServerConfig,
|
||
|
|
options: ICreateMcpToolsOptions,
|
||
|
|
): Promise<IMcpClientLike> => {
|
||
|
|
if (config.type === 'client') {
|
||
|
|
return config.client;
|
||
|
|
}
|
||
|
|
|
||
|
|
const client = new Client(
|
||
|
|
{
|
||
|
|
name: config.clientName ?? options.clientName ?? 'smartagent-mcp',
|
||
|
|
version: config.clientVersion ?? options.clientVersion ?? '1.0.0',
|
||
|
|
},
|
||
|
|
config.clientOptions ?? options.clientOptions ?? { capabilities: {} },
|
||
|
|
);
|
||
|
|
const transport = createMcpTransport(config);
|
||
|
|
await client.connect(transport, config.requestOptions ?? options.requestOptions);
|
||
|
|
return client;
|
||
|
|
};
|
||
|
|
|
||
|
|
const createMcpTransport = (
|
||
|
|
config: Exclude<TMcpServerConfig, IMcpClientServerConfig>,
|
||
|
|
): Transport => {
|
||
|
|
if (config.type === 'stdio') {
|
||
|
|
const { type, clientName, clientVersion, clientOptions, requestOptions, ...serverParameters } = config;
|
||
|
|
return new StdioClientTransport(serverParameters);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (config.type === 'streamableHttp' || config.type === 'http') {
|
||
|
|
return new StreamableHTTPClientTransport(new URL(config.url), config.transportOptions);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (config.type === 'transport') {
|
||
|
|
return config.transport;
|
||
|
|
}
|
||
|
|
|
||
|
|
throw new Error('Unsupported MCP server transport.');
|
||
|
|
};
|
||
|
|
|
||
|
|
const getServerRequestOptions = (config: TMcpServerConfig): RequestOptions | undefined => {
|
||
|
|
return config.type === 'client' ? undefined : config.requestOptions;
|
||
|
|
};
|
||
|
|
|
||
|
|
const listAllMcpTools = async (
|
||
|
|
client: IMcpClientLike,
|
||
|
|
requestOptions?: RequestOptions,
|
||
|
|
): Promise<TMcpToolMetadata[]> => {
|
||
|
|
const tools: TMcpToolMetadata[] = [];
|
||
|
|
let cursor: string | undefined;
|
||
|
|
do {
|
||
|
|
const result = await client.listTools(cursor ? { cursor } : undefined, requestOptions);
|
||
|
|
tools.push(...result.tools);
|
||
|
|
cursor = result.nextCursor;
|
||
|
|
} while (cursor);
|
||
|
|
return tools;
|
||
|
|
};
|
||
|
|
|
||
|
|
const createExposedToolName = (
|
||
|
|
serverName: string,
|
||
|
|
toolName: string,
|
||
|
|
prefixToolName: boolean,
|
||
|
|
): string => {
|
||
|
|
const safeToolName = sanitizeMcpToolName(toolName);
|
||
|
|
if (!prefixToolName) {
|
||
|
|
return safeToolName;
|
||
|
|
}
|
||
|
|
return `${sanitizeMcpToolName(serverName, 'mcp')}__${safeToolName}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const createUniqueToolName = (baseToolName: string, usedToolNames: Set<string>): string => {
|
||
|
|
if (!usedToolNames.has(baseToolName)) {
|
||
|
|
return baseToolName;
|
||
|
|
}
|
||
|
|
let suffix = 2;
|
||
|
|
while (usedToolNames.has(`${baseToolName}_${suffix}`)) {
|
||
|
|
suffix++;
|
||
|
|
}
|
||
|
|
return `${baseToolName}_${suffix}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const normalizeMcpArguments = (input: unknown): Record<string, unknown> => {
|
||
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||
|
|
return {};
|
||
|
|
}
|
||
|
|
return input as Record<string, unknown>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatMcpContentPart = (part: unknown): string => {
|
||
|
|
if (!part || typeof part !== 'object') {
|
||
|
|
return stringifyJson(part);
|
||
|
|
}
|
||
|
|
const partRecord = part as Record<string, unknown>;
|
||
|
|
const type = partRecord.type;
|
||
|
|
if (type === 'text') {
|
||
|
|
return typeof partRecord.text === 'string' ? partRecord.text : '';
|
||
|
|
}
|
||
|
|
if (type === 'image' || type === 'audio') {
|
||
|
|
const data = typeof partRecord.data === 'string' ? partRecord.data : '';
|
||
|
|
const mimeType = typeof partRecord.mimeType === 'string' ? partRecord.mimeType : 'application/octet-stream';
|
||
|
|
return `[${type}: ${mimeType}; base64 length ${data.length}]`;
|
||
|
|
}
|
||
|
|
if (type === 'resource') {
|
||
|
|
return formatMcpResourcePart(partRecord.resource);
|
||
|
|
}
|
||
|
|
if (type === 'resource_link') {
|
||
|
|
const name = typeof partRecord.name === 'string' ? partRecord.name : 'resource';
|
||
|
|
const uri = typeof partRecord.uri === 'string' ? partRecord.uri : '';
|
||
|
|
return `Resource link ${name}${uri ? `: ${uri}` : ''}`;
|
||
|
|
}
|
||
|
|
return stringifyJson(part);
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatMcpResourcePart = (resource: unknown): string => {
|
||
|
|
if (!resource || typeof resource !== 'object') {
|
||
|
|
return stringifyJson(resource);
|
||
|
|
}
|
||
|
|
const resourceRecord = resource as Record<string, unknown>;
|
||
|
|
const uri = typeof resourceRecord.uri === 'string' ? resourceRecord.uri : 'resource';
|
||
|
|
if (typeof resourceRecord.text === 'string') {
|
||
|
|
return `Resource ${uri}:\n${resourceRecord.text}`;
|
||
|
|
}
|
||
|
|
if (typeof resourceRecord.blob === 'string') {
|
||
|
|
const mimeType = typeof resourceRecord.mimeType === 'string' ? resourceRecord.mimeType : 'application/octet-stream';
|
||
|
|
return `[resource ${uri}: ${mimeType}; base64 length ${resourceRecord.blob.length}]`;
|
||
|
|
}
|
||
|
|
return stringifyJson(resource);
|
||
|
|
};
|
||
|
|
|
||
|
|
const stringifyJson = (value: unknown): string => {
|
||
|
|
if (typeof value === 'string') {
|
||
|
|
return value;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
return JSON.stringify(value, undefined, 2);
|
||
|
|
} catch {
|
||
|
|
return String(value);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const closeMcpClients = async (servers: IMcpConnectedServer[]): Promise<void> => {
|
||
|
|
await Promise.all(servers.map((server) => server.client.close?.()));
|
||
|
|
};
|