@langchain/mcp-adapters
Version:
LangChain.js adapters for Model Context Protocol (MCP)
300 lines (299 loc) • 11.2 kB
JavaScript
import { DynamicStructuredTool } from "@langchain/core/tools";
import debug from "debug";
import { _resolveDetailedOutputHandling, } from "./types.js";
// Replace direct initialization with lazy initialization
let debugLog;
function getDebugLog() {
if (!debugLog) {
debugLog = debug("@langchain/mcp-adapters:tools");
}
return debugLog;
}
/**
* Custom error class for tool exceptions
*/
export class ToolException extends Error {
constructor(message) {
super(message);
this.name = "ToolException";
}
}
export function isToolException(error) {
return (typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "ToolException");
}
function isResourceReference(resource) {
return (typeof resource === "object" &&
resource !== null &&
resource.uri != null &&
resource.blob == null &&
resource.text == null);
}
async function* _embeddedResourceToStandardFileBlocks(resource, client) {
if (isResourceReference(resource)) {
const response = await client.readResource({
uri: resource.uri,
});
for (const content of response.contents) {
yield* _embeddedResourceToStandardFileBlocks(content, client);
}
return;
}
if (resource.blob != null) {
yield {
type: "file",
source_type: "base64",
data: resource.blob,
mime_type: resource.mimeType,
...(resource.uri != null ? { metadata: { uri: resource.uri } } : {}),
};
}
if (resource.text != null) {
yield {
type: "file",
source_type: "text",
mime_type: resource.mimeType,
text: resource.text,
...(resource.uri != null ? { metadata: { uri: resource.uri } } : {}),
};
}
}
async function _toolOutputToContentBlocks(content, useStandardContentBlocks, client, toolName, serverName) {
const blocks = [];
switch (content.type) {
case "text":
return [
{
type: "text",
...(useStandardContentBlocks
? {
source_type: "text",
}
: {}),
text: content.text,
},
];
case "image":
if (useStandardContentBlocks) {
return [
{
type: "image",
source_type: "base64",
data: content.data,
mime_type: content.mimeType,
},
];
}
return [
{
type: "image_url",
image_url: {
url: `data:${content.mimeType};base64,${content.data}`,
},
},
];
case "audio":
// We don't check `useStandardContentBlocks` here because we only support audio via
// standard content blocks
return [
{
type: "audio",
source_type: "base64",
data: content.data,
mime_type: content.mimeType,
},
];
case "resource":
for await (const block of _embeddedResourceToStandardFileBlocks(content.resource, client)) {
blocks.push(block);
}
return blocks;
default:
throw new ToolException(`MCP tool '${toolName}' on server '${serverName}' returned a content block with unexpected type "${content.type}." Expected one of "text", "image", or "audio".`);
}
}
async function _embeddedResourceToArtifact(resource, useStandardContentBlocks, client, toolName, serverName) {
if (useStandardContentBlocks) {
return _toolOutputToContentBlocks(resource, useStandardContentBlocks, client, toolName, serverName);
}
if (!resource.blob && !resource.text && resource.uri) {
const response = await client.readResource({
uri: resource.resource.uri,
});
return response.contents.map((content) => ({
type: "resource",
resource: {
...content,
},
}));
}
return [resource];
}
function _getOutputTypeForContentType(contentType, outputHandling) {
if (outputHandling === "content" || outputHandling === "artifact") {
return outputHandling;
}
const resolved = _resolveDetailedOutputHandling(outputHandling);
return (resolved[contentType] ??
(contentType === "resource" ? "artifact" : "content"));
}
/**
* Process the result from calling an MCP tool.
* Extracts text content and non-text content for better agent compatibility.
*
* @internal
*
* @param args - The arguments to pass to the tool
* @returns A tuple of [textContent, nonTextContent]
*/
async function _convertCallToolResult({ serverName, toolName, result, client, useStandardContentBlocks, outputHandling, }) {
if (!result) {
throw new ToolException(`MCP tool '${toolName}' on server '${serverName}' returned an invalid result - tool call response was undefined`);
}
if (!Array.isArray(result.content)) {
throw new ToolException(`MCP tool '${toolName}' on server '${serverName}' returned an invalid result - expected an array of content, but was ${typeof result.content}`);
}
if (result.isError) {
throw new ToolException(`MCP tool '${toolName}' on server '${serverName}' returned an error: ${result.content
.map((content) => content.text)
.join("\n")}`);
}
const convertedContent = (await Promise.all(result.content
.filter((content) => _getOutputTypeForContentType(content.type, outputHandling) ===
"content")
.map((content) => _toolOutputToContentBlocks(content, useStandardContentBlocks, client, toolName, serverName)))).flat();
// Create the text content output
const artifacts = (await Promise.all(result.content.filter((content) => _getOutputTypeForContentType(content.type, outputHandling) ===
"artifact").map((content) => {
return _embeddedResourceToArtifact(content, useStandardContentBlocks, client, toolName, serverName);
}))).flat();
if (convertedContent.length === 1 && convertedContent[0].type === "text") {
return [convertedContent[0].text, artifacts];
}
return [convertedContent, artifacts];
}
/**
* Call an MCP tool.
*
* Use this with `.bind` to capture the fist three arguments, then pass to the constructor of DynamicStructuredTool.
*
* @internal
* @param args - The arguments to pass to the tool
* @returns A tuple of [textContent, nonTextContent]
*/
async function _callTool({ serverName, toolName, client, args, config, useStandardContentBlocks, outputHandling, }) {
try {
getDebugLog()(`INFO: Calling tool ${toolName}(${JSON.stringify(args)})`);
// Extract timeout from RunnableConfig and pass to MCP SDK
const requestOptions = {
...(config?.timeout ? { timeout: config.timeout } : {}),
...(config?.signal ? { signal: config.signal } : {}),
};
const callToolArgs = [
{
name: toolName,
arguments: args,
},
];
if (Object.keys(requestOptions).length > 0) {
callToolArgs.push(undefined); // optional output schema arg
callToolArgs.push(requestOptions);
}
const result = await client.callTool(...callToolArgs);
return _convertCallToolResult({
serverName,
toolName,
result: result,
client,
useStandardContentBlocks,
outputHandling,
});
}
catch (error) {
getDebugLog()(`Error calling tool ${toolName}: ${String(error)}`);
if (isToolException(error)) {
throw error;
}
throw new ToolException(`Error calling tool ${toolName}: ${String(error)}`);
}
}
const defaultLoadMcpToolsOptions = {
throwOnLoadError: true,
prefixToolNameWithServerName: false,
additionalToolNamePrefix: "",
useStandardContentBlocks: false,
};
/**
* Load all tools from an MCP client.
*
* @param serverName - The name of the server to load tools from
* @param client - The MCP client
* @returns A list of LangChain tools
*/
export async function loadMcpTools(serverName, client, options) {
const { throwOnLoadError, prefixToolNameWithServerName, additionalToolNamePrefix, useStandardContentBlocks, outputHandling, defaultToolTimeout, } = {
...defaultLoadMcpToolsOptions,
...(options ?? {}),
};
const mcpTools = [];
// Get tools in a single operation
let toolsResponse;
do {
toolsResponse = await client.listTools({
...(toolsResponse?.nextCursor
? { cursor: toolsResponse.nextCursor }
: {}),
});
mcpTools.push(...(toolsResponse.tools || []));
} while (toolsResponse.nextCursor);
getDebugLog()(`INFO: Found ${mcpTools.length} MCP tools`);
const initialPrefix = additionalToolNamePrefix
? `${additionalToolNamePrefix}__`
: "";
const serverPrefix = prefixToolNameWithServerName ? `${serverName}__` : "";
const toolNamePrefix = `${initialPrefix}${serverPrefix}`;
// Filter out tools without names and convert in a single map operation
return (await Promise.all(mcpTools
.filter((tool) => !!tool.name)
.map(async (tool) => {
try {
if (!tool.inputSchema.properties) {
// Workaround for MCP SDK not consistently providing properties
// eslint-disable-next-line no-param-reassign
tool.inputSchema.properties = {};
}
const dst = new DynamicStructuredTool({
name: `${toolNamePrefix}${tool.name}`,
description: tool.description || "",
schema: tool.inputSchema,
responseFormat: "content_and_artifact",
metadata: { annotations: tool.annotations },
defaultConfig: defaultToolTimeout
? { timeout: defaultToolTimeout }
: undefined,
func: async (args, _runManager, config) => {
return _callTool({
serverName,
toolName: tool.name,
client,
args,
config,
useStandardContentBlocks,
outputHandling,
});
},
});
getDebugLog()(`INFO: Successfully loaded tool: ${dst.name}`);
return dst;
}
catch (error) {
getDebugLog()(`ERROR: Failed to load tool "${tool.name}":`, error);
if (throwOnLoadError) {
throw error;
}
return null;
}
}))).filter(Boolean);
}