feat(mcp): add MCP tool integration via dedicated subpath export

This commit is contained in:
2026-05-18 08:53:57 +00:00
parent cf45add905
commit 9957485281
6 changed files with 1095 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
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?.()));
};