mirror of
https://github.com/langchain-ai/langchainjs-mcp-adapters.git
synced 2026-07-21 08:36:31 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e264a3f61b | |||
| 40ffbf4080 | |||
| 99d6be0626 | |||
| 3a59cf313b | |||
| 72b740ffcf | |||
| 399bf78cd8 | |||
| 8cd572316e | |||
| 6bc0b0703e |
@@ -3,7 +3,7 @@
|
||||
[](https://www.npmjs.com/package/@langchain/mcp-adapters)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
This library provides a lightweight wrapper that makes [Anthropic Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) tools compatible with [LangChain.js](https://github.com/langchain-ai/langchainjs) and [LangGraph.js](https://github.com/langchain-ai/langgraphjs).
|
||||
This library provides a lightweight wrapper that makes[Anthropic Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) tools compatible with [LangChain.js](https://github.com/langchain-ai/langchainjs) and [LangGraph.js](https://github.com/langchain-ai/langgraphjs).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -25,7 +25,6 @@ This library provides a lightweight wrapper that makes [Anthropic Model Context
|
||||
- Optimized for OpenAI, Anthropic, and Google models
|
||||
|
||||
- 🛠️ **Development Features**
|
||||
- Comprehensive logging system
|
||||
- Flexible configuration options
|
||||
- Robust error handling
|
||||
|
||||
@@ -491,33 +490,30 @@ When using in browsers:
|
||||
|
||||
### Debug Logging
|
||||
|
||||
Logging is disabled by default for optimal performance. Enable logging when needed for diagnostics:
|
||||
This package makes use of the [debug](https://www.npmjs.com/package/debug) package for debug logging.
|
||||
|
||||
```typescript
|
||||
import { enableLogging, disableLogging } from '@langchain/mcp-adapters';
|
||||
Logging is disabled by default, and can be enabled by setting the `DEBUG` environment variable as per
|
||||
the instructions in the debug package.
|
||||
|
||||
// Enable logging at info level (default)
|
||||
enableLogging();
|
||||
To output all debug logs from this package:
|
||||
|
||||
// Or specify a specific level
|
||||
enableLogging('debug'); // Most verbose
|
||||
enableLogging('info'); // General information
|
||||
enableLogging('warn'); // Warnings only
|
||||
enableLogging('error'); // Errors only
|
||||
|
||||
// Disable logging when done
|
||||
disableLogging();
|
||||
```bash
|
||||
DEBUG='@langchain/mcp-adapters:*'
|
||||
```
|
||||
|
||||
You can also access the logger directly:
|
||||
To output debug logs only from the `client` module:
|
||||
|
||||
```typescript
|
||||
import { logger } from '@langchain/mcp-adapters';
|
||||
|
||||
// Advanced logging configuration
|
||||
logger.level = 'debug';
|
||||
```bash
|
||||
DEBUG='@langchain/mcp-adapters:client'
|
||||
```
|
||||
|
||||
To output debug logs only from the `tools` module:
|
||||
|
||||
```bash
|
||||
DEBUG='@langchain/mcp-adapters:tools'
|
||||
```
|
||||
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
// Mock fs and path modules
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
unlinkSync: vi.fn(),
|
||||
accessSync: vi.fn(),
|
||||
}));
|
||||
vi.mock('path', () => ({
|
||||
join: vi.fn(),
|
||||
}));
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const winston = await import('winston');
|
||||
|
||||
describe('Logger', () => {
|
||||
// Store original console.warn implementation
|
||||
const originalConsoleWarn = console.warn;
|
||||
let consoleWarnMock: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear module cache to ensure logger is reinitialized
|
||||
vi.resetModules();
|
||||
|
||||
// Mock console.warn to capture warnings
|
||||
consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation((..._args) => {});
|
||||
|
||||
// Configure path.join to return predictable paths
|
||||
(path.join as any).mockImplementation((...args: string[]) => args.join('/'));
|
||||
|
||||
// Reset fs mock implementation
|
||||
(fs.existsSync as any).mockReset();
|
||||
(fs.mkdirSync as any).mockReset();
|
||||
(fs.writeFileSync as any).mockReset();
|
||||
(fs.unlinkSync as any).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore console.warn
|
||||
consoleWarnMock.mockRestore();
|
||||
console.warn = originalConsoleWarn;
|
||||
});
|
||||
|
||||
test('should fallback to console-only logging when directory creation fails', async () => {
|
||||
// Mock fs.existsSync to return false (directory doesn't exist)
|
||||
(fs.existsSync as any).mockReturnValue(false);
|
||||
|
||||
// Mock fs.mkdirSync to throw an error
|
||||
(fs.mkdirSync as any).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
// Import logger (after mocks are set up)
|
||||
const logger = (await import('../src/logger.js')).default;
|
||||
|
||||
// Verify console warning was logged
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unable to set up file logging')
|
||||
);
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith('Falling back to console logging only');
|
||||
|
||||
// Ensure logger was created with only console transport
|
||||
expect(logger.transports.length).toBe(1);
|
||||
expect(logger.transports[0]).toBeInstanceOf(winston.transports.Console);
|
||||
});
|
||||
|
||||
test('should fallback to console-only logging when write permission test fails', async () => {
|
||||
// Mock fs.existsSync to return true (directory exists)
|
||||
(fs.existsSync as any).mockReturnValue(true);
|
||||
|
||||
// Mock fs.writeFileSync to throw an error
|
||||
(fs.writeFileSync as any).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
// Import logger (after mocks are set up)
|
||||
const logger = (await import('../src/logger.js')).default;
|
||||
|
||||
// Verify console warning was logged
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unable to set up file logging')
|
||||
);
|
||||
expect(consoleWarnMock).toHaveBeenCalledWith('Falling back to console logging only');
|
||||
|
||||
// Ensure logger was created with only console transport
|
||||
expect(logger.transports.length).toBe(1);
|
||||
expect(logger.transports[0]).toBeInstanceOf(winston.transports.Console);
|
||||
});
|
||||
|
||||
test('should set up file transports when permissions are available', async () => {
|
||||
// Mock all the file operations to succeed
|
||||
(fs.mkdirSync as any).mockImplementation(() => true);
|
||||
(fs.accessSync as any).mockImplementation(() => true);
|
||||
(fs.existsSync as any).mockReturnValue(true);
|
||||
(fs.writeFileSync as any).mockImplementation(() => undefined);
|
||||
|
||||
// Import logger directly
|
||||
const loggerModule = await import('../src/logger.js');
|
||||
const logger = loggerModule.default;
|
||||
|
||||
// Just verify logger was created - don't worry about warnings
|
||||
expect(logger).toBeDefined();
|
||||
expect(typeof logger.debug).toBe('function');
|
||||
expect(typeof logger.info).toBe('function');
|
||||
expect(typeof logger.warn).toBe('function');
|
||||
expect(typeof logger.error).toBe('function');
|
||||
});
|
||||
});
|
||||
+113
-40
@@ -1,14 +1,24 @@
|
||||
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||
import { describe, test, expect, beforeEach, vi, MockedObject } from 'vitest';
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type { StructuredTool } from '@langchain/core/tools';
|
||||
import type {
|
||||
EmbeddedResource,
|
||||
ImageContent,
|
||||
TextContent,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { AIMessage, MessageContentComplex, ToolMessage } from '@langchain/core/messages';
|
||||
const { loadMcpTools } = await import('../src/tools.js');
|
||||
|
||||
// Create a mock client
|
||||
const mockClient = {
|
||||
callTool: vi.fn(),
|
||||
listTools: vi.fn(),
|
||||
};
|
||||
|
||||
describe('Simplified Tool Adapter Tests', () => {
|
||||
let mockClient: MockedObject<Client>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = {
|
||||
callTool: vi.fn(),
|
||||
listTools: vi.fn(),
|
||||
} as MockedObject<Client>;
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -18,14 +28,22 @@ describe('Simplified Tool Adapter Tests', () => {
|
||||
mockClient.listTools.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
tools: [
|
||||
{ name: 'tool1', description: 'Tool 1' },
|
||||
{ name: 'tool2', description: 'Tool 2' },
|
||||
{
|
||||
name: 'tool1',
|
||||
description: 'Tool 1',
|
||||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
{
|
||||
name: 'tool2',
|
||||
description: 'Tool 2',
|
||||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Load tools
|
||||
const tools = await loadMcpTools(mockClient as unknown as Parameters<typeof loadMcpTools>[0]);
|
||||
const tools = await loadMcpTools('mockServer(should load all tools)', mockClient as Client);
|
||||
|
||||
// Verify results
|
||||
expect(tools.length).toBe(2);
|
||||
@@ -42,7 +60,10 @@ describe('Simplified Tool Adapter Tests', () => {
|
||||
);
|
||||
|
||||
// Load tools
|
||||
const tools = await loadMcpTools(mockClient as unknown as Parameters<typeof loadMcpTools>[0]);
|
||||
const tools = await loadMcpTools(
|
||||
'mockServer(should handle empty tool list)',
|
||||
mockClient as Client
|
||||
);
|
||||
|
||||
// Verify results
|
||||
expect(tools.length).toBe(0);
|
||||
@@ -51,17 +72,32 @@ describe('Simplified Tool Adapter Tests', () => {
|
||||
test('should filter out tools without names', async () => {
|
||||
// Set up mock response
|
||||
mockClient.listTools.mockReturnValueOnce(
|
||||
// @ts-expect-error - Purposefully dropped name field on one of the tools, should be type error.
|
||||
Promise.resolve({
|
||||
tools: [
|
||||
{ name: 'tool1', description: 'Tool 1' },
|
||||
{ description: 'No name tool' }, // Should be filtered out
|
||||
{ name: 'tool2', description: 'Tool 2' },
|
||||
{
|
||||
name: 'tool1',
|
||||
description: 'Tool 1',
|
||||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
{
|
||||
description: 'No name tool',
|
||||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
{
|
||||
name: 'tool2',
|
||||
description: 'Tool 2',
|
||||
inputSchema: { type: 'object', properties: {}, required: [] },
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Load tools
|
||||
const tools = await loadMcpTools(mockClient as unknown as Parameters<typeof loadMcpTools>[0]);
|
||||
const tools = await loadMcpTools(
|
||||
'mockServer(should filter out tools without names)',
|
||||
mockClient as Client
|
||||
);
|
||||
|
||||
// Verify results
|
||||
expect(tools.length).toBe(2);
|
||||
@@ -91,19 +127,64 @@ describe('Simplified Tool Adapter Tests', () => {
|
||||
|
||||
// Load tools with content_and_artifact response format
|
||||
const tools = await loadMcpTools(
|
||||
mockClient as unknown as Parameters<typeof loadMcpTools>[0],
|
||||
'content_and_artifact'
|
||||
'mockServer(should load tools with specified response format)',
|
||||
mockClient as Client
|
||||
);
|
||||
|
||||
// Verify tool was loaded
|
||||
expect(tools.length).toBe(1);
|
||||
expect((tools[0] as any).responseFormat).toBe('content');
|
||||
expect((tools[0] as StructuredTool).responseFormat).toBe('content_and_artifact');
|
||||
|
||||
// Mock the call result to check response format handling
|
||||
const mockImageContent = { type: 'image', url: 'http://example.com/image.jpg' };
|
||||
mockClient.callTool.mockReturnValueOnce(
|
||||
const mockImageContent: ImageContent = {
|
||||
type: 'image',
|
||||
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', // valid grayscale PNG image
|
||||
mimeType: 'image/png',
|
||||
};
|
||||
|
||||
const mockTextContent: TextContent = {
|
||||
type: 'text',
|
||||
text: 'Here is your image',
|
||||
};
|
||||
|
||||
const mockEmbeddedResourceContent: EmbeddedResource = {
|
||||
type: 'resource',
|
||||
resource: {
|
||||
text: 'Here is your image',
|
||||
uri: 'test-data://test-artifact',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
};
|
||||
|
||||
const mockContent = [mockTextContent, mockImageContent, mockEmbeddedResourceContent];
|
||||
|
||||
const expectedContentBlocks: MessageContentComplex[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Here is your image',
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const expectedArtifacts = [
|
||||
{
|
||||
type: 'resource',
|
||||
resource: {
|
||||
text: 'Here is your image',
|
||||
uri: 'test-data://test-artifact',
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockClient.callTool.mockReturnValue(
|
||||
Promise.resolve({
|
||||
content: [{ type: 'text', text: 'Image result' }, mockImageContent],
|
||||
content: mockContent,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -111,29 +192,21 @@ describe('Simplified Tool Adapter Tests', () => {
|
||||
const result = await tools[0].invoke({ input: 'test input' });
|
||||
|
||||
// Verify the result
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result[0]).toBe('Image result');
|
||||
expect(result[1]).toEqual([mockImageContent]);
|
||||
expect(result).toEqual(expectedContentBlocks);
|
||||
|
||||
// Set up a second mock response for the direct call test
|
||||
mockClient.callTool.mockReturnValueOnce(
|
||||
Promise.resolve({
|
||||
content: [{ type: 'text', text: 'Image result' }, mockImageContent],
|
||||
})
|
||||
);
|
||||
const toolCall: NonNullable<AIMessage['tool_calls']>[number] = {
|
||||
args: { input: 'test input' },
|
||||
name: 'tool1',
|
||||
id: 'tool_call_id_123',
|
||||
type: 'tool_call',
|
||||
};
|
||||
|
||||
// Access the tool class implementation through the prototype chain
|
||||
const toolPrototype = Object.getPrototypeOf(tools[0]);
|
||||
// Call the _call method directly with bound 'this' context
|
||||
const directResult = await toolPrototype._call.call(tools[0], { input: 'test input' });
|
||||
// call the tool directly via invoke
|
||||
const toolMessageResult: ToolMessage = await tools[0].invoke(toolCall);
|
||||
|
||||
expect(Array.isArray(directResult)).toBe(true);
|
||||
|
||||
const [textContent, nonTextContent] = directResult as [string, any[]];
|
||||
|
||||
expect(textContent).toBe('Image result');
|
||||
expect(nonTextContent.length).toBe(1);
|
||||
expect(nonTextContent[0]).toEqual(mockImageContent);
|
||||
expect(toolMessageResult.tool_call_id).toBe(toolCall.id);
|
||||
expect(toolMessageResult.content).toEqual(expectedContentBlocks);
|
||||
expect(toolMessageResult.artifact).toEqual(expectedArtifacts);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,16 +5,14 @@
|
||||
* and directly connecting to the local math_server.py script using LangGraph.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import dotenv from 'dotenv';
|
||||
import logger from '../src/logger.js';
|
||||
import { StateGraph, END, START, MessagesAnnotation } from '@langchain/langgraph';
|
||||
import { ToolNode } from '@langchain/langgraph/prebuilt';
|
||||
import { HumanMessage, AIMessage, SystemMessage } from '@langchain/core/messages';
|
||||
import { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
// MCP client imports
|
||||
import { MultiServerMCPClient } from '../src/index.js';
|
||||
@@ -29,10 +27,10 @@ dotenv.config();
|
||||
async function runConfigTest() {
|
||||
try {
|
||||
// Log when we start
|
||||
logger.info('Starting test with configuration files...');
|
||||
console.log('Starting test with configuration files...');
|
||||
|
||||
// Step 1: Load and verify auth_mcp.json configuration (just testing parsing)
|
||||
logger.info('Parsing auth_mcp.json configuration...');
|
||||
console.log('Parsing auth_mcp.json configuration...');
|
||||
const authConfigPath = path.join(process.cwd(), 'examples', 'auth_mcp.json');
|
||||
|
||||
if (!fs.existsSync(authConfigPath)) {
|
||||
@@ -41,13 +39,18 @@ async function runConfigTest() {
|
||||
|
||||
// Load the auth configuration to verify it parses correctly
|
||||
const authConfig = JSON.parse(fs.readFileSync(authConfigPath, 'utf-8'));
|
||||
logger.info('Successfully parsed auth_mcp.json with the following servers:');
|
||||
logger.info('Servers:', Object.keys(authConfig.servers));
|
||||
console.log('Successfully parsed auth_mcp.json with the following servers:');
|
||||
console.log('Servers:', Object.keys(authConfig.servers));
|
||||
|
||||
// Print auth headers (redacted for security) to verify they're present
|
||||
Object.entries(authConfig.servers).forEach(([serverName, serverConfig]: [string, any]) => {
|
||||
if (serverConfig.headers) {
|
||||
logger.info(
|
||||
Object.entries(authConfig.servers).forEach(([serverName, serverConfig]) => {
|
||||
if (
|
||||
serverConfig &&
|
||||
typeof serverConfig === 'object' &&
|
||||
'headers' in serverConfig &&
|
||||
serverConfig.headers
|
||||
) {
|
||||
console.log(
|
||||
`Server ${serverName} has headers:`,
|
||||
Object.keys(serverConfig.headers).map(key => `${key}: ***`)
|
||||
);
|
||||
@@ -55,7 +58,7 @@ async function runConfigTest() {
|
||||
});
|
||||
|
||||
// Step 2: Load and verify complex_mcp.json configuration
|
||||
logger.info('Parsing complex_mcp.json configuration...');
|
||||
console.log('Parsing complex_mcp.json configuration...');
|
||||
const complexConfigPath = path.join(process.cwd(), 'examples', 'complex_mcp.json');
|
||||
|
||||
if (!fs.existsSync(complexConfigPath)) {
|
||||
@@ -63,11 +66,11 @@ async function runConfigTest() {
|
||||
}
|
||||
|
||||
const complexConfig = JSON.parse(fs.readFileSync(complexConfigPath, 'utf-8'));
|
||||
logger.info('Successfully parsed complex_mcp.json with the following servers:');
|
||||
logger.info('Servers:', Object.keys(complexConfig.servers));
|
||||
console.log('Successfully parsed complex_mcp.json with the following servers:');
|
||||
console.log('Servers:', Object.keys(complexConfig.servers));
|
||||
|
||||
// Step 3: Connect directly to the math server using explicit path
|
||||
logger.info('Connecting to math server directly...');
|
||||
console.log('Connecting to math server directly...');
|
||||
|
||||
// Define the python executable (use 'python3' on systems where 'python' might not be in PATH)
|
||||
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3';
|
||||
@@ -85,12 +88,12 @@ async function runConfigTest() {
|
||||
await client.initializeConnections();
|
||||
|
||||
// Get tools from the math server
|
||||
const mcpTools = client.getTools() as StructuredToolInterface<z.ZodObject<any>>[];
|
||||
logger.info(`Loaded ${mcpTools.length} tools from math server`);
|
||||
const mcpTools = client.getTools();
|
||||
console.log(`Loaded ${mcpTools.length} tools from math server`);
|
||||
|
||||
// Log the names of available tools
|
||||
const toolNames = mcpTools.map(tool => tool.name);
|
||||
logger.info('Available tools:', toolNames.join(', '));
|
||||
console.log('Available tools:', toolNames.join(', '));
|
||||
|
||||
// Create an OpenAI model for the agent
|
||||
const model = new ChatOpenAI({
|
||||
@@ -103,37 +106,37 @@ async function runConfigTest() {
|
||||
|
||||
// Define the function that calls the model
|
||||
const llmNode = async (state: typeof MessagesAnnotation.State) => {
|
||||
logger.info('Calling LLM with messages:', state.messages.length);
|
||||
console.log('Calling LLM with messages:', state.messages.length);
|
||||
const response = await model.invoke(state.messages);
|
||||
return { messages: [response] };
|
||||
};
|
||||
|
||||
// Create a new graph with MessagesAnnotation
|
||||
const workflow = new StateGraph(MessagesAnnotation);
|
||||
const workflow = new StateGraph(MessagesAnnotation)
|
||||
|
||||
// Add the nodes to the graph
|
||||
workflow.addNode('llm', llmNode);
|
||||
workflow.addNode('tools', toolNode);
|
||||
// Add the nodes to the graph
|
||||
.addNode('llm', llmNode)
|
||||
.addNode('tools', toolNode)
|
||||
|
||||
// Add edges - need to cast to any to fix TypeScript errors
|
||||
workflow.addEdge(START as any, 'llm' as any);
|
||||
workflow.addEdge('tools' as any, 'llm' as any);
|
||||
// Add edges - need to cast to any to fix TypeScript errors
|
||||
.addEdge(START, 'llm')
|
||||
.addEdge('tools', 'llm')
|
||||
|
||||
// Add conditional logic to determine the next step
|
||||
workflow.addConditionalEdges('llm' as any, state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
// Add conditional logic to determine the next step
|
||||
.addConditionalEdges('llm', state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
// If the last message has tool calls, we need to execute the tools
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
logger.info('Tool calls detected, routing to tools node');
|
||||
return 'tools' as any;
|
||||
}
|
||||
// If the last message has tool calls, we need to execute the tools
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
console.log('Tool calls detected, routing to tools node');
|
||||
return 'tools';
|
||||
}
|
||||
|
||||
// If there are no tool calls, we're done
|
||||
logger.info('No tool calls, ending the workflow');
|
||||
return END as any;
|
||||
});
|
||||
// If there are no tool calls, we're done
|
||||
console.log('No tool calls, ending the workflow');
|
||||
return END;
|
||||
});
|
||||
|
||||
// Compile the graph
|
||||
const app = workflow.compile();
|
||||
@@ -148,7 +151,7 @@ async function runConfigTest() {
|
||||
|
||||
// Run each test query
|
||||
for (const query of testQueries) {
|
||||
logger.info(`\n=== Running query: "${query}" ===`);
|
||||
console.log(`\n=== Running query: "${query}" ===`);
|
||||
|
||||
try {
|
||||
// Create initial messages with a system message and the user query
|
||||
@@ -165,29 +168,29 @@ async function runConfigTest() {
|
||||
// Get the last AI message as the response
|
||||
const lastMessage = result.messages.filter(message => message._getType() === 'ai').pop();
|
||||
|
||||
logger.info(`\nFinal Answer: ${lastMessage?.content}`);
|
||||
console.log(`\nFinal Answer: ${lastMessage?.content}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error processing query "${query}":`, error);
|
||||
console.error(`Error processing query "${query}":`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Close all connections
|
||||
logger.info('\nClosing connections...');
|
||||
console.log('\nClosing connections...');
|
||||
await client.close();
|
||||
|
||||
logger.info('Test completed successfully');
|
||||
console.log('Test completed successfully');
|
||||
} catch (error) {
|
||||
logger.error('Error running test:', error);
|
||||
console.error('Error running test:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
runConfigTest()
|
||||
.then(() => {
|
||||
logger.info('Configuration test completed successfully');
|
||||
console.log('Configuration test completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Error running configuration test:', error);
|
||||
console.error('Error running configuration test:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -10,14 +10,12 @@
|
||||
* 3. Structured handling of complex multi-file operations
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { StateGraph, END, START, MessagesAnnotation } from '@langchain/langgraph';
|
||||
import { ToolNode } from '@langchain/langgraph/prebuilt';
|
||||
import { HumanMessage, AIMessage, SystemMessage } from '@langchain/core/messages';
|
||||
import { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
import logger from '../src/logger.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
@@ -31,37 +29,37 @@ dotenv.config();
|
||||
* Example demonstrating how to use MCP filesystem tools with LangGraph agent flows
|
||||
* This example focuses on file operations like reading multiple files and writing files
|
||||
*/
|
||||
async function runExample() {
|
||||
let client: MultiServerMCPClient | null = null;
|
||||
|
||||
export async function runExample(client?: MultiServerMCPClient) {
|
||||
try {
|
||||
logger.info('Initializing MCP client...');
|
||||
console.log('Initializing MCP client...');
|
||||
|
||||
// Create a client with configurations for the filesystem server
|
||||
client = new MultiServerMCPClient({
|
||||
filesystem: {
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'-y',
|
||||
'@modelcontextprotocol/server-filesystem',
|
||||
'./examples/filesystem_test', // This directory needs to exist
|
||||
],
|
||||
},
|
||||
});
|
||||
client =
|
||||
client ??
|
||||
new MultiServerMCPClient({
|
||||
filesystem: {
|
||||
transport: 'stdio',
|
||||
command: 'npx',
|
||||
args: [
|
||||
'-y',
|
||||
'@modelcontextprotocol/server-filesystem',
|
||||
'./examples/filesystem_test', // This directory needs to exist
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize connections to the server
|
||||
await client.initializeConnections();
|
||||
logger.info('Connected to server');
|
||||
console.log('Connected to server');
|
||||
|
||||
// Get all tools (flattened array is the default now)
|
||||
const mcpTools = client.getTools() as StructuredToolInterface<z.ZodObject<any>>[];
|
||||
const mcpTools = client.getTools();
|
||||
|
||||
if (mcpTools.length === 0) {
|
||||
throw new Error('No tools found');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
console.log(
|
||||
`Loaded ${mcpTools.length} MCP tools: ${mcpTools.map(tool => tool.name).join(', ')}`
|
||||
);
|
||||
|
||||
@@ -76,7 +74,7 @@ For file writing operations, format the content properly based on the file type.
|
||||
For reading multiple files, you can use the read_multiple_files tool.`;
|
||||
|
||||
const model = new ChatOpenAI({
|
||||
modelName: process.env.OPENAI_MODEL_NAME || 'gpt-4-turbo-preview',
|
||||
modelName: process.env.OPENAI_MODEL_NAME || 'gpt-4o-mini',
|
||||
temperature: 0,
|
||||
}).bindTools(mcpTools);
|
||||
|
||||
@@ -86,11 +84,11 @@ For reading multiple files, you can use the read_multiple_files tool.`;
|
||||
// ================================================
|
||||
// Create a LangGraph agent flow
|
||||
// ================================================
|
||||
logger.info('\n=== CREATING LANGGRAPH AGENT FLOW ===');
|
||||
console.log('\n=== CREATING LANGGRAPH AGENT FLOW ===');
|
||||
|
||||
// Define the function that calls the model
|
||||
const llmNode = async (state: typeof MessagesAnnotation.State) => {
|
||||
logger.info(`Calling LLM with ${state.messages.length} messages`);
|
||||
console.log(`Calling LLM with ${state.messages.length} messages`);
|
||||
|
||||
// Add system message if it's the first call
|
||||
let messages = state.messages;
|
||||
@@ -103,36 +101,36 @@ For reading multiple files, you can use the read_multiple_files tool.`;
|
||||
};
|
||||
|
||||
// Create a new graph with MessagesAnnotation
|
||||
const workflow = new StateGraph(MessagesAnnotation);
|
||||
const workflow = new StateGraph(MessagesAnnotation)
|
||||
|
||||
// Add the nodes to the graph
|
||||
workflow.addNode('llm', llmNode);
|
||||
workflow.addNode('tools', toolNode);
|
||||
// Add the nodes to the graph
|
||||
.addNode('llm', llmNode)
|
||||
.addNode('tools', toolNode)
|
||||
|
||||
// Add edges - these define how nodes are connected
|
||||
workflow.addEdge(START as any, 'llm' as any);
|
||||
workflow.addEdge('tools' as any, 'llm' as any);
|
||||
// Add edges - these define how nodes are connected
|
||||
.addEdge(START, 'llm')
|
||||
.addEdge('tools', 'llm')
|
||||
|
||||
// Conditional routing to end or continue the tool loop
|
||||
workflow.addConditionalEdges('llm' as any, state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
// Conditional routing to end or continue the tool loop
|
||||
.addConditionalEdges('llm', state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
// Cast to AIMessage to access tool_calls property
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
logger.info('Tool calls detected, routing to tools node');
|
||||
// Cast to AIMessage to access tool_calls property
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
console.log('Tool calls detected, routing to tools node');
|
||||
|
||||
// Log what tools are being called
|
||||
const toolNames = aiMessage.tool_calls.map(tc => tc.name).join(', ');
|
||||
logger.info(`Tools being called: ${toolNames}`);
|
||||
// Log what tools are being called
|
||||
const toolNames = aiMessage.tool_calls.map(tc => tc.name).join(', ');
|
||||
console.log(`Tools being called: ${toolNames}`);
|
||||
|
||||
return 'tools' as any;
|
||||
}
|
||||
return 'tools';
|
||||
}
|
||||
|
||||
// If there are no tool calls, we're done
|
||||
logger.info('No tool calls, ending the workflow');
|
||||
return END as any;
|
||||
});
|
||||
// If there are no tool calls, we're done
|
||||
console.log('No tool calls, ending the workflow');
|
||||
return END;
|
||||
});
|
||||
|
||||
// Compile the graph
|
||||
const app = workflow.compile();
|
||||
@@ -165,8 +163,8 @@ For reading multiple files, you can use the read_multiple_files tool.`;
|
||||
console.log('\n=== RUNNING LANGGRAPH AGENT ===');
|
||||
|
||||
for (const example of examples) {
|
||||
logger.info(`\n--- Example: ${example.name} ---`);
|
||||
logger.info(`Query: ${example.query}`);
|
||||
console.log(`\n--- Example: ${example.name} ---`);
|
||||
console.log(`Query: ${example.query}`);
|
||||
|
||||
// Run the LangGraph agent
|
||||
const result = await app.invoke({
|
||||
@@ -175,10 +173,10 @@ For reading multiple files, you can use the read_multiple_files tool.`;
|
||||
|
||||
// Display the final answer
|
||||
const finalMessage = result.messages[result.messages.length - 1];
|
||||
logger.info(`\nResult: ${finalMessage.content}`);
|
||||
console.log(`\nResult: ${finalMessage.content}`);
|
||||
|
||||
// Let's list the directory to see the changes
|
||||
logger.info('\nDirectory listing after operations:');
|
||||
console.log('\nDirectory listing after operations:');
|
||||
try {
|
||||
const listResult = await app.invoke({
|
||||
messages: [
|
||||
@@ -188,23 +186,23 @@ For reading multiple files, you can use the read_multiple_files tool.`;
|
||||
],
|
||||
});
|
||||
const listMessage = listResult.messages[listResult.messages.length - 1];
|
||||
logger.info(listMessage.content);
|
||||
console.log(listMessage.content);
|
||||
} catch (error) {
|
||||
logger.error('Error listing directory:', error);
|
||||
console.error('Error listing directory:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error:', error);
|
||||
console.error('Error:', error);
|
||||
process.exit(1); // Exit with error code
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.close();
|
||||
logger.info('Closed all MCP connections');
|
||||
console.log('Closed all MCP connections');
|
||||
}
|
||||
|
||||
// Exit process after a short delay to allow for cleanup
|
||||
setTimeout(() => {
|
||||
logger.info('Example completed, exiting process.');
|
||||
console.log('Example completed, exiting process.');
|
||||
process.exit(0);
|
||||
}, 500);
|
||||
}
|
||||
@@ -218,11 +216,13 @@ async function setupTestDirectory() {
|
||||
|
||||
if (!fs.existsSync(testDir)) {
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
logger.info(`Created test directory: ${testDir}`);
|
||||
console.log(`Created test directory: ${testDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set up test directory and run the example
|
||||
setupTestDirectory()
|
||||
.then(() => runExample())
|
||||
.catch(error => logger.error('Setup error:', error));
|
||||
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMainModule) {
|
||||
setupTestDirectory()
|
||||
.then(() => runExample())
|
||||
.catch(error => console.error('Setup error:', error));
|
||||
}
|
||||
|
||||
@@ -5,14 +5,12 @@
|
||||
* And getting tools from the Firecrawl server with automatic initialization
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import logger from '../src/logger.js';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
|
||||
// MCP client imports
|
||||
@@ -61,18 +59,18 @@ async function runExample() {
|
||||
try {
|
||||
// Create a custom configuration file
|
||||
const configPath = createConfigFile();
|
||||
logger.info(`Created custom configuration file at: ${configPath}`);
|
||||
console.log(`Created custom configuration file at: ${configPath}`);
|
||||
|
||||
// Initialize the MCP client with the custom configuration
|
||||
logger.info('Initializing MCP client from custom configuration file...');
|
||||
console.log('Initializing MCP client from custom configuration file...');
|
||||
client = MultiServerMCPClient.fromConfigFile(configPath);
|
||||
|
||||
// Connect to the servers
|
||||
await client.initializeConnections();
|
||||
logger.info('Connected to servers from custom configuration');
|
||||
console.log('Connected to servers from custom configuration');
|
||||
|
||||
// Get Firecrawl tools specifically
|
||||
const mcpTools = client.getTools() as StructuredToolInterface<z.ZodObject<any>>[];
|
||||
const mcpTools = client.getTools();
|
||||
const firecrawlTools = mcpTools.filter(
|
||||
tool => client!.getServerForTool(tool.name) === 'firecrawl'
|
||||
);
|
||||
@@ -81,7 +79,7 @@ async function runExample() {
|
||||
throw new Error('No Firecrawl tools found');
|
||||
}
|
||||
|
||||
logger.info(`Found ${firecrawlTools.length} Firecrawl tools`);
|
||||
console.log(`Found ${firecrawlTools.length} Firecrawl tools`);
|
||||
|
||||
// Initialize the LLM
|
||||
const model = new ChatOpenAI({
|
||||
@@ -99,14 +97,14 @@ async function runExample() {
|
||||
const query =
|
||||
'Find the latest news about artificial intelligence and summarize the top 3 stories';
|
||||
|
||||
logger.info(`Running agent with query: ${query}`);
|
||||
console.log(`Running agent with query: ${query}`);
|
||||
|
||||
// Run the agent
|
||||
const result = await agent.invoke({
|
||||
messages: [new HumanMessage(query)],
|
||||
});
|
||||
|
||||
logger.info('Agent execution completed');
|
||||
console.log('Agent execution completed');
|
||||
console.log('\nFinal output:');
|
||||
console.log(result);
|
||||
|
||||
@@ -115,22 +113,22 @@ async function runExample() {
|
||||
|
||||
// Clean up the temporary configuration file
|
||||
fs.unlinkSync(configPath);
|
||||
logger.info('Removed temporary configuration file');
|
||||
console.log('Removed temporary configuration file');
|
||||
} catch (error) {
|
||||
logger.error('Error in example:', error);
|
||||
console.error('Error in example:', error);
|
||||
} finally {
|
||||
// Close all MCP connections
|
||||
if (client) {
|
||||
logger.info('Closing all MCP connections...');
|
||||
console.log('Closing all MCP connections...');
|
||||
await client.close();
|
||||
logger.info('All MCP connections closed');
|
||||
console.log('All MCP connections closed');
|
||||
}
|
||||
|
||||
// Clear the timeout if it hasn't fired yet
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Complete the example
|
||||
logger.info('Example execution completed');
|
||||
console.log('Example execution completed');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@
|
||||
* And getting tools from the Firecrawl server with automatic initialization
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
import logger, { enableLogging } from '../src/logger.js';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
|
||||
// MCP client imports
|
||||
@@ -23,9 +21,6 @@ dotenv.config();
|
||||
* Example demonstrating loading from default configuration
|
||||
*/
|
||||
async function runExample() {
|
||||
// Enable logging for testing
|
||||
enableLogging('debug');
|
||||
|
||||
let client: MultiServerMCPClient | null = null;
|
||||
|
||||
// Add a timeout to prevent the process from hanging indefinitely
|
||||
@@ -35,15 +30,15 @@ async function runExample() {
|
||||
}, 30000);
|
||||
|
||||
try {
|
||||
logger.info('Initializing MCP client from default configuration file...');
|
||||
console.log('Initializing MCP client from default configuration file...');
|
||||
|
||||
// The client will automatically look for and load mcp.json from the current directory
|
||||
client = new MultiServerMCPClient();
|
||||
await client.initializeConnections();
|
||||
logger.info('Connected to servers from default configuration');
|
||||
console.log('Connected to servers from default configuration');
|
||||
|
||||
// Get Firecrawl tools specifically
|
||||
const mcpTools = client.getTools() as StructuredToolInterface<z.ZodObject<any>>[];
|
||||
const mcpTools = client.getTools();
|
||||
const firecrawlTools = mcpTools.filter(
|
||||
tool => client!.getServerForTool(tool.name) === 'firecrawl'
|
||||
);
|
||||
@@ -52,7 +47,7 @@ async function runExample() {
|
||||
throw new Error('No Firecrawl tools found');
|
||||
}
|
||||
|
||||
logger.info(`Found ${firecrawlTools.length} Firecrawl tools`);
|
||||
console.log(`Found ${firecrawlTools.length} Firecrawl tools`);
|
||||
|
||||
// Initialize the LLM
|
||||
const model = new ChatOpenAI({
|
||||
@@ -69,34 +64,34 @@ async function runExample() {
|
||||
// Define a query for testing Firecrawl
|
||||
const query = 'Scrape the content from https://example.com and summarize it in bullet points';
|
||||
|
||||
logger.info(`Running agent with query: ${query}`);
|
||||
console.log(`Running agent with query: ${query}`);
|
||||
|
||||
// Run the agent
|
||||
const result = await agent.invoke({
|
||||
messages: [new HumanMessage(query)],
|
||||
});
|
||||
|
||||
logger.info('Agent execution completed');
|
||||
console.log('Agent execution completed');
|
||||
console.log('\nFinal output:');
|
||||
console.log(result);
|
||||
|
||||
// Clear the timeout since the example completed successfully
|
||||
clearTimeout(timeout);
|
||||
} catch (error) {
|
||||
logger.error('Error in example:', error);
|
||||
console.log('Error in example:', error);
|
||||
} finally {
|
||||
// Close all MCP connections
|
||||
if (client) {
|
||||
logger.info('Closing all MCP connections...');
|
||||
console.log('Closing all MCP connections...');
|
||||
await client.close();
|
||||
logger.info('All MCP connections closed');
|
||||
console.log('All MCP connections closed');
|
||||
}
|
||||
|
||||
// Clear the timeout if it hasn't fired yet
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Complete the example
|
||||
logger.info('Example execution completed');
|
||||
console.log('Example execution completed');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,14 @@
|
||||
* 3. Environment variable substitution
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { StateGraph, END, START, MessagesAnnotation } from '@langchain/langgraph';
|
||||
import { ToolNode } from '@langchain/langgraph/prebuilt';
|
||||
import { HumanMessage, AIMessage, BaseMessage } from '@langchain/core/messages';
|
||||
import { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import { HumanMessage, AIMessage } from '@langchain/core/messages';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../src/logger.js';
|
||||
|
||||
// MCP client imports
|
||||
import { MultiServerMCPClient } from '../src/index.js';
|
||||
@@ -43,7 +41,7 @@ function createAdditionalConfigFile() {
|
||||
};
|
||||
|
||||
fs.writeFileSync(additionalConfigPath, JSON.stringify(configContent, null, 2));
|
||||
logger.info(`Created additional configuration file at ${additionalConfigPath}`);
|
||||
console.log(`Created additional configuration file at ${additionalConfigPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +60,7 @@ async function runExample() {
|
||||
// Create the additional configuration file
|
||||
createAdditionalConfigFile();
|
||||
|
||||
logger.info('Initializing MCP client with enhanced configuration loading...');
|
||||
console.log('Initializing MCP client with enhanced configuration loading...');
|
||||
|
||||
// Create a client - it will automatically load from mcp.json if it exists
|
||||
client = new MultiServerMCPClient();
|
||||
@@ -82,16 +80,16 @@ async function runExample() {
|
||||
|
||||
// Initialize all connections from the merged configurations
|
||||
await client.initializeConnections();
|
||||
logger.info('Connected to servers from all configurations');
|
||||
console.log('Connected to servers from all configurations');
|
||||
|
||||
// Get all tools from all servers
|
||||
const mcpTools = client.getTools() as StructuredToolInterface<z.ZodObject<any>>[];
|
||||
const mcpTools = client.getTools();
|
||||
|
||||
if (mcpTools.length === 0) {
|
||||
throw new Error('No tools found');
|
||||
}
|
||||
|
||||
logger.info(`Loaded ${mcpTools.length} MCP tools in total`);
|
||||
console.log(`Loaded ${mcpTools.length} MCP tools in total`);
|
||||
|
||||
// Filter tools from different servers
|
||||
const firecrawlTools = mcpTools.filter(
|
||||
@@ -99,12 +97,12 @@ async function runExample() {
|
||||
);
|
||||
|
||||
if (firecrawlTools.length === 0) {
|
||||
logger.warn('No Firecrawl tools found, using math tools for the example');
|
||||
console.log('No Firecrawl tools found, using math tools for the example');
|
||||
// In this case, use math tools as a fallback
|
||||
const mathTools = mcpTools.filter(tool => client!.getServerForTool(tool.name) === 'math');
|
||||
|
||||
if (mathTools.length > 0) {
|
||||
logger.info(
|
||||
console.log(
|
||||
`Using ${mathTools.length} math tools: ${mathTools.map(tool => tool.name).join(', ')}`
|
||||
);
|
||||
// Run a simple math example and exit
|
||||
@@ -116,7 +114,7 @@ async function runExample() {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
console.log(
|
||||
`Loaded ${firecrawlTools.length} Firecrawl tools: ${firecrawlTools.map(tool => tool.name).join(', ')}`
|
||||
);
|
||||
|
||||
@@ -142,29 +140,29 @@ async function runExample() {
|
||||
};
|
||||
|
||||
// Create a new graph with MessagesAnnotation
|
||||
const workflow = new StateGraph(MessagesAnnotation);
|
||||
const workflow = new StateGraph(MessagesAnnotation)
|
||||
|
||||
// Add the nodes to the graph
|
||||
workflow.addNode('llm', llmNode);
|
||||
workflow.addNode('tools', toolNode);
|
||||
// Add the nodes to the graph
|
||||
.addNode('llm', llmNode)
|
||||
.addNode('tools', toolNode)
|
||||
|
||||
// Add edges - these define how nodes are connected
|
||||
workflow.addEdge(START as any, 'llm' as any);
|
||||
workflow.addEdge('tools' as any, 'llm' as any);
|
||||
// Add edges - these define how nodes are connected
|
||||
.addEdge(START, 'llm')
|
||||
.addEdge('tools', 'llm')
|
||||
|
||||
// Conditional routing to end or continue the tool loop
|
||||
workflow.addConditionalEdges('llm' as any, state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
// Conditional routing to end or continue the tool loop
|
||||
.addConditionalEdges('llm', state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
console.log('Tool calls detected, routing to tools node');
|
||||
return 'tools' as any;
|
||||
}
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
console.log('Tool calls detected, routing to tools node');
|
||||
return 'tools';
|
||||
}
|
||||
|
||||
console.log('No tool calls, ending the workflow');
|
||||
return END as any;
|
||||
});
|
||||
console.log('No tool calls, ending the workflow');
|
||||
return END;
|
||||
});
|
||||
|
||||
// Compile the graph
|
||||
const app = workflow.compile();
|
||||
@@ -184,7 +182,7 @@ async function runExample() {
|
||||
});
|
||||
|
||||
// Run with a 20-second timeout
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error('LangGraph execution timed out after 20 seconds')),
|
||||
20000
|
||||
@@ -192,7 +190,7 @@ async function runExample() {
|
||||
});
|
||||
|
||||
// Race between the LangGraph execution and the timeout
|
||||
const result = (await Promise.race([langgraphPromise, timeoutPromise])) as any;
|
||||
const result = await Promise.race([langgraphPromise, timeoutPromise]);
|
||||
|
||||
// Display the final response
|
||||
const finalMessage = result.messages[result.messages.length - 1];
|
||||
@@ -217,7 +215,7 @@ async function runExample() {
|
||||
// Clean up our additional config file
|
||||
if (fs.existsSync(additionalConfigPath)) {
|
||||
fs.unlinkSync(additionalConfigPath);
|
||||
logger.info(`Cleaned up additional configuration file at ${additionalConfigPath}`);
|
||||
console.log(`Cleaned up additional configuration file at ${additionalConfigPath}`);
|
||||
}
|
||||
|
||||
// Exit process after a short delay to allow for cleanup
|
||||
|
||||
@@ -6,16 +6,14 @@
|
||||
* 2. Adding the firecrawl server directly in code
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { StateGraph, END, START, MessagesAnnotation } from '@langchain/langgraph';
|
||||
import { ToolNode } from '@langchain/langgraph/prebuilt';
|
||||
import { HumanMessage, AIMessage, BaseMessage } from '@langchain/core/messages';
|
||||
import { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../src/logger.js';
|
||||
|
||||
// MCP client imports
|
||||
import { MultiServerMCPClient } from '../src/index.js';
|
||||
@@ -41,7 +39,7 @@ function createMathServerConfigFile() {
|
||||
};
|
||||
|
||||
fs.writeFileSync(partialConfigPath, JSON.stringify(configContent, null, 2));
|
||||
logger.info(`Created math server configuration file at ${partialConfigPath}`);
|
||||
console.log(`Created math server configuration file at ${partialConfigPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,17 +53,17 @@ async function runExample() {
|
||||
// Create the math server configuration file
|
||||
createMathServerConfigFile();
|
||||
|
||||
logger.info('Initializing MCP client from math server configuration file...');
|
||||
console.log('Initializing MCP client from math server configuration file...');
|
||||
|
||||
// Create a client from the configuration file
|
||||
client = MultiServerMCPClient.fromConfigFile(partialConfigPath);
|
||||
|
||||
// Initialize connections to the math server
|
||||
await client.initializeConnections();
|
||||
logger.info('Connected to math server from configuration');
|
||||
console.log('Connected to math server from configuration');
|
||||
|
||||
// Now add the firecrawl server directly in code
|
||||
logger.info('Adding firecrawl server directly in code...');
|
||||
console.log('Adding firecrawl server directly in code...');
|
||||
await client.connectToServerViaStdio('firecrawl', 'npx', ['-y', 'firecrawl-mcp'], {
|
||||
// Adding the API key from environment
|
||||
FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY || '',
|
||||
@@ -73,10 +71,10 @@ async function runExample() {
|
||||
FIRECRAWL_RETRY_MAX_ATTEMPTS: '3',
|
||||
});
|
||||
|
||||
logger.info('Connected to firecrawl server directly');
|
||||
console.log('Connected to firecrawl server directly');
|
||||
|
||||
// Get all tools from all servers
|
||||
const mcpTools = client.getTools() as StructuredToolInterface<z.ZodObject<any>>[];
|
||||
const mcpTools = client.getTools();
|
||||
|
||||
if (mcpTools.length === 0) {
|
||||
throw new Error('No tools found');
|
||||
@@ -88,13 +86,13 @@ async function runExample() {
|
||||
tool => client!.getServerForTool(tool.name) === 'firecrawl'
|
||||
);
|
||||
|
||||
logger.info(
|
||||
console.log(
|
||||
`Loaded ${mathTools.length} math tools: ${mathTools.map(tool => tool.name).join(', ')}`
|
||||
);
|
||||
logger.info(
|
||||
console.log(
|
||||
`Loaded ${firecrawlTools.length} firecrawl tools: ${firecrawlTools.map(tool => tool.name).join(', ')}`
|
||||
);
|
||||
logger.info(`Loaded ${mcpTools.length} tools in total`);
|
||||
console.log(`Loaded ${mcpTools.length} tools in total`);
|
||||
|
||||
// Create an OpenAI model and bind the tools
|
||||
const model = new ChatOpenAI({
|
||||
@@ -118,29 +116,29 @@ async function runExample() {
|
||||
};
|
||||
|
||||
// Create a new graph with MessagesAnnotation
|
||||
const workflow = new StateGraph(MessagesAnnotation);
|
||||
const workflow = new StateGraph(MessagesAnnotation)
|
||||
|
||||
// Add the nodes to the graph
|
||||
workflow.addNode('llm', llmNode);
|
||||
workflow.addNode('tools', toolNode);
|
||||
// Add the nodes to the graph
|
||||
.addNode('llm', llmNode)
|
||||
.addNode('tools', toolNode)
|
||||
|
||||
// Add edges - these define how nodes are connected
|
||||
workflow.addEdge(START as any, 'llm' as any);
|
||||
workflow.addEdge('tools' as any, 'llm' as any);
|
||||
// Add edges - these define how nodes are connected
|
||||
.addEdge(START, 'llm')
|
||||
.addEdge('tools', 'llm')
|
||||
|
||||
// Conditional routing to end or continue the tool loop
|
||||
workflow.addConditionalEdges('llm' as any, state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
// Conditional routing to end or continue the tool loop
|
||||
.addConditionalEdges('llm', state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
console.log('Tool calls detected, routing to tools node');
|
||||
return 'tools' as any;
|
||||
}
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
console.log('Tool calls detected, routing to tools node');
|
||||
return 'tools';
|
||||
}
|
||||
|
||||
console.log('No tool calls, ending the workflow');
|
||||
return END as any;
|
||||
});
|
||||
console.log('No tool calls, ending the workflow');
|
||||
return END;
|
||||
});
|
||||
|
||||
// Compile the graph
|
||||
const app = workflow.compile();
|
||||
@@ -182,7 +180,7 @@ async function runExample() {
|
||||
// Clean up our config file
|
||||
if (fs.existsSync(partialConfigPath)) {
|
||||
fs.unlinkSync(partialConfigPath);
|
||||
logger.info(`Cleaned up math server configuration file at ${partialConfigPath}`);
|
||||
console.log(`Cleaned up math server configuration file at ${partialConfigPath}`);
|
||||
}
|
||||
|
||||
// Exit process after a short delay to allow for cleanup
|
||||
|
||||
@@ -5,18 +5,16 @@
|
||||
* It includes both the Firecrawl server for web scraping and the Math server for calculations.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../src/logger.js';
|
||||
|
||||
// MCP client imports
|
||||
import { MultiServerMCPClient, enableLogging } from '../src/index.js';
|
||||
import { MultiServerMCPClient } from '../src/index.js';
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
@@ -54,7 +52,7 @@ function createMultipleServersConfigFile() {
|
||||
};
|
||||
|
||||
fs.writeFileSync(multipleServersConfigPath, JSON.stringify(configContent, null, 2));
|
||||
logger.info(`Created multiple servers configuration file at ${multipleServersConfigPath}`);
|
||||
console.log(`Created multiple servers configuration file at ${multipleServersConfigPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,29 +63,26 @@ async function runExample() {
|
||||
let client: MultiServerMCPClient | null = null;
|
||||
|
||||
try {
|
||||
// Enable logging for better visibility
|
||||
enableLogging('info');
|
||||
|
||||
// Create the multiple servers configuration file
|
||||
createMultipleServersConfigFile();
|
||||
|
||||
logger.info('Initializing MCP client from multiple servers configuration file...');
|
||||
console.log('Initializing MCP client from multiple servers configuration file...');
|
||||
|
||||
// Create a client from the configuration file
|
||||
client = MultiServerMCPClient.fromConfigFile(multipleServersConfigPath);
|
||||
|
||||
// Initialize connections to all servers in the configuration
|
||||
await client.initializeConnections();
|
||||
logger.info('Connected to servers from multiple servers configuration');
|
||||
console.log('Connected to servers from multiple servers configuration');
|
||||
|
||||
// Get all tools from all servers
|
||||
const mcpTools = client.getTools() as StructuredToolInterface<z.ZodObject<any>>[];
|
||||
const mcpTools = client.getTools();
|
||||
|
||||
if (mcpTools.length === 0) {
|
||||
throw new Error('No tools found');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
console.log(
|
||||
`Loaded ${mcpTools.length} MCP tools: ${mcpTools.map(tool => tool.name).join(', ')}`
|
||||
);
|
||||
|
||||
@@ -143,7 +138,7 @@ async function runExample() {
|
||||
// Clean up our config file
|
||||
if (fs.existsSync(multipleServersConfigPath)) {
|
||||
fs.unlinkSync(multipleServersConfigPath);
|
||||
logger.info(`Cleaned up multiple servers configuration file at ${multipleServersConfigPath}`);
|
||||
console.log(`Cleaned up multiple servers configuration file at ${multipleServersConfigPath}`);
|
||||
}
|
||||
|
||||
// Exit process after a short delay to allow for cleanup
|
||||
|
||||
@@ -22,14 +22,12 @@
|
||||
* - Ability to expand the graph with additional nodes for more complex workflows
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { StateGraph, END, START, MessagesAnnotation } from '@langchain/langgraph';
|
||||
import { ToolNode } from '@langchain/langgraph/prebuilt';
|
||||
import { HumanMessage, AIMessage, BaseMessage } from '@langchain/core/messages';
|
||||
import { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
import logger from '../src/logger.js';
|
||||
|
||||
// MCP client imports
|
||||
import { MultiServerMCPClient } from '../src/index.js';
|
||||
@@ -45,7 +43,7 @@ async function runExample() {
|
||||
let client: MultiServerMCPClient | null = null;
|
||||
|
||||
try {
|
||||
logger.info('Initializing MCP client...');
|
||||
console.log('Initializing MCP client...');
|
||||
|
||||
// Create a client with configurations for the math server only
|
||||
client = new MultiServerMCPClient({
|
||||
@@ -58,7 +56,7 @@ async function runExample() {
|
||||
|
||||
// Initialize connections to the server
|
||||
await client.initializeConnections();
|
||||
logger.info('Connected to server');
|
||||
console.log('Connected to server');
|
||||
|
||||
// Connect to the math server
|
||||
await client.connectToServerViaStdio('math', 'npx', [
|
||||
@@ -67,13 +65,13 @@ async function runExample() {
|
||||
]);
|
||||
|
||||
// Get the tools (flattened array is the default now)
|
||||
const mcpTools = client.getTools() as StructuredToolInterface<z.ZodObject<any>>[];
|
||||
const mcpTools = client.getTools();
|
||||
|
||||
if (mcpTools.length === 0) {
|
||||
throw new Error('No tools found');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
console.log(
|
||||
`Loaded ${mcpTools.length} MCP tools: ${mcpTools.map(tool => tool.name).join(', ')}`
|
||||
);
|
||||
|
||||
@@ -107,35 +105,35 @@ async function runExample() {
|
||||
};
|
||||
|
||||
// Create a new graph with MessagesAnnotation
|
||||
const workflow = new StateGraph(MessagesAnnotation);
|
||||
const workflow = new StateGraph(MessagesAnnotation)
|
||||
|
||||
// Add the nodes to the graph
|
||||
workflow.addNode('llm', llmNode);
|
||||
workflow.addNode('tools', toolNode);
|
||||
// Add the nodes to the graph
|
||||
.addNode('llm', llmNode)
|
||||
.addNode('tools', toolNode)
|
||||
|
||||
// Add edges - these define how nodes are connected
|
||||
// START -> llm: Entry point to the graph
|
||||
// tools -> llm: After tools are executed, return to LLM for next step
|
||||
workflow.addEdge(START as any, 'llm' as any);
|
||||
workflow.addEdge('tools' as any, 'llm' as any);
|
||||
// Add edges - these define how nodes are connected
|
||||
// START -> llm: Entry point to the graph
|
||||
// tools -> llm: After tools are executed, return to LLM for next step
|
||||
.addEdge(START, 'llm')
|
||||
.addEdge('tools', 'llm')
|
||||
|
||||
// Conditional routing to end or continue the tool loop
|
||||
// This is the core of the agent's decision-making process
|
||||
workflow.addConditionalEdges('llm' as any, state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
// Conditional routing to end or continue the tool loop
|
||||
// This is the core of the agent's decision-making process
|
||||
.addConditionalEdges('llm', state => {
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
|
||||
// If the last message has tool calls, we need to execute the tools
|
||||
// Cast to AIMessage to access tool_calls property
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
console.log('Tool calls detected, routing to tools node');
|
||||
return 'tools' as any;
|
||||
}
|
||||
// If the last message has tool calls, we need to execute the tools
|
||||
// Cast to AIMessage to access tool_calls property
|
||||
const aiMessage = lastMessage as AIMessage;
|
||||
if (aiMessage.tool_calls && aiMessage.tool_calls.length > 0) {
|
||||
console.log('Tool calls detected, routing to tools node');
|
||||
return 'tools';
|
||||
}
|
||||
|
||||
// If there are no tool calls, we're done
|
||||
console.log('No tool calls, ending the workflow');
|
||||
return END as any;
|
||||
});
|
||||
// If there are no tool calls, we're done
|
||||
console.log('No tool calls, ending the workflow');
|
||||
return END;
|
||||
});
|
||||
|
||||
// Compile the graph
|
||||
// This creates a runnable LangChain object that we can invoke
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Filesystem MCP Server with LangGraph Example
|
||||
*
|
||||
* This example demonstrates how to use the Filesystem MCP server with LangGraph
|
||||
* to create a structured workflow for complex file operations.
|
||||
*
|
||||
* The graph-based approach allows:
|
||||
* 1. Clear separation of responsibilities (reasoning vs execution)
|
||||
* 2. Conditional routing based on file operation types
|
||||
* 3. Structured handling of complex multi-file operations
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
import { MultiServerMCPClient } from '../src/index.js';
|
||||
import { runExample as runFileSystemExample } from './filesystem_langgraph_example.js';
|
||||
|
||||
async function runExample() {
|
||||
const client = new MultiServerMCPClient({
|
||||
filesystem: {
|
||||
transport: 'stdio',
|
||||
command: 'docker',
|
||||
args: [
|
||||
'run',
|
||||
'-i',
|
||||
'--rm',
|
||||
'-v',
|
||||
'mcp-filesystem-data:/projects',
|
||||
'mcp/filesystem',
|
||||
'/projects',
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await runFileSystemExample(client);
|
||||
}
|
||||
|
||||
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMainModule) {
|
||||
runExample().catch(error => console.error('Setup error:', error));
|
||||
}
|
||||
Generated
+641
-263
File diff suppressed because it is too large
Load Diff
+6
-4
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@langchain/mcp-adapters",
|
||||
"version": "0.2.4",
|
||||
"version": "0.3.0",
|
||||
"description": "LangChain.js adapters for Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"main": "dist/src/index.js",
|
||||
"types": "dist/src/index.d.ts",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -43,7 +43,7 @@
|
||||
"dependencies": {
|
||||
"@dmitryrechkin/json-schema-to-zod": "^1.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.7.0",
|
||||
"winston": "^3.17.0"
|
||||
"debug": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@langchain/core": "^0.3.40"
|
||||
@@ -55,9 +55,11 @@
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@langchain/langgraph": "^0.2.56",
|
||||
"@langchain/openai": "^0.4.4",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^7.3.1",
|
||||
"@typescript-eslint/parser": "^7.3.1",
|
||||
"@vitest/coverage-v8": "^3.0.9",
|
||||
"dotenv": "^16.4.7",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^10.0.2",
|
||||
|
||||
+91
-72
@@ -1,11 +1,18 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||
import { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import type { StructuredToolInterface } from '@langchain/core/tools';
|
||||
import { loadMcpTools } from './tools.js';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import logger from './logger.js';
|
||||
import debug from 'debug';
|
||||
|
||||
const {
|
||||
default: { name: packageName },
|
||||
} = await import('../package.json');
|
||||
const moduleName = 'client';
|
||||
|
||||
const debugLog = debug(`${packageName}:${moduleName}`);
|
||||
|
||||
/**
|
||||
* Configuration for stdio transport connection
|
||||
@@ -105,28 +112,30 @@ export class MultiServerMCPClient {
|
||||
*/
|
||||
constructor(connections?: Record<string, Connection>) {
|
||||
if (connections) {
|
||||
this.connections = this.processConnections(connections);
|
||||
this.connections = MultiServerMCPClient.processConnections(connections);
|
||||
} else {
|
||||
// Try to load from default mcp.json if no connections are provided
|
||||
this.tryLoadDefaultConfig();
|
||||
this.connections = MultiServerMCPClient.tryLoadDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to load the default configuration file (mcp.json) from the root directory
|
||||
*/
|
||||
private tryLoadDefaultConfig(): void {
|
||||
private static tryLoadDefaultConfig(): Record<string, Connection> | undefined {
|
||||
try {
|
||||
const defaultConfigPath = path.join(process.cwd(), 'mcp.json');
|
||||
if (fs.existsSync(defaultConfigPath)) {
|
||||
logger.info(`Found default configuration at ${defaultConfigPath}, loading automatically`);
|
||||
const config = this.loadConfigFromFile(defaultConfigPath);
|
||||
this.connections = this.processConnections(config.servers);
|
||||
debugLog(
|
||||
`INFO: Found default configuration at ${defaultConfigPath}, loading automatically`
|
||||
);
|
||||
const config = MultiServerMCPClient.loadConfigFromFile(defaultConfigPath);
|
||||
return MultiServerMCPClient.processConnections(config.servers);
|
||||
} else {
|
||||
logger.debug('No default mcp.json found in root directory');
|
||||
debugLog(`INFO: No default mcp.json found in root directory`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to load default configuration: ${error}`);
|
||||
debugLog(`WARN: Failed to load default configuration: ${error}`);
|
||||
// Do not throw here, just continue with no configs
|
||||
}
|
||||
}
|
||||
@@ -137,18 +146,24 @@ export class MultiServerMCPClient {
|
||||
* @param configPath - Path to the configuration file
|
||||
* @returns The parsed configuration
|
||||
*/
|
||||
private loadConfigFromFile(configPath: string): MCPConfig {
|
||||
private static loadConfigFromFile(configPath: string): MCPConfig {
|
||||
const configData = fs.readFileSync(configPath, 'utf8');
|
||||
const config = JSON.parse(configData);
|
||||
|
||||
// Validate that config has a servers property
|
||||
if (!config || typeof config !== 'object' || !('servers' in config)) {
|
||||
logger.error(`Invalid MCP configuration from ${configPath}: missing 'servers' property`);
|
||||
debugLog(`ERROR: Invalid MCP configuration from ${configPath}: missing 'servers' property`);
|
||||
throw new MCPClientError(`Invalid MCP configuration: missing 'servers' property`);
|
||||
}
|
||||
|
||||
// Process environment variables in the configuration
|
||||
this.processEnvVarsInConfig(config.servers);
|
||||
MultiServerMCPClient.processEnvVarsInConfig(
|
||||
Object.fromEntries(
|
||||
Object.entries(config.servers as Record<string, StdioConnection>).filter(
|
||||
([_, value]) => value.transport === 'stdio'
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -159,7 +174,7 @@ export class MultiServerMCPClient {
|
||||
*
|
||||
* @param servers - The servers configuration object
|
||||
*/
|
||||
private processEnvVarsInConfig(servers: Record<string, any>): void {
|
||||
private static processEnvVarsInConfig(servers: Record<string, StdioConnection>): void {
|
||||
for (const [serverName, config] of Object.entries(servers)) {
|
||||
if (typeof config !== 'object' || config === null) continue;
|
||||
|
||||
@@ -172,14 +187,14 @@ export class MultiServerMCPClient {
|
||||
if (envValue) {
|
||||
config.env[key] = envValue;
|
||||
} else {
|
||||
logger.warn(`Environment variable ${envVar} not found for server "${serverName}"`);
|
||||
debugLog(`WARN: Environment variable ${envVar} not found for server "${serverName}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any other string properties recursively
|
||||
this.processEnvVarsRecursively(config);
|
||||
MultiServerMCPClient.processEnvVarsRecursively(config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +203,7 @@ export class MultiServerMCPClient {
|
||||
*
|
||||
* @param obj - The object to process
|
||||
*/
|
||||
private processEnvVarsRecursively(obj: any): void {
|
||||
private static processEnvVarsRecursively<T extends object>(obj: T): void {
|
||||
if (typeof obj !== 'object' || obj === null) return;
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
@@ -196,11 +211,11 @@ export class MultiServerMCPClient {
|
||||
const envVar = value.slice(2, -1);
|
||||
const envValue = process.env[envVar];
|
||||
if (envValue) {
|
||||
obj[key] = envValue;
|
||||
obj[key as keyof T] = envValue as T[keyof T];
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null && key !== 'env') {
|
||||
// Skip env object as it's handled separately
|
||||
this.processEnvVarsRecursively(value);
|
||||
MultiServerMCPClient.processEnvVarsRecursively(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,14 +226,14 @@ export class MultiServerMCPClient {
|
||||
* @param connections - Raw connection configurations
|
||||
* @returns Processed connection configurations
|
||||
*/
|
||||
private processConnections(
|
||||
private static processConnections(
|
||||
connections: Record<string, Partial<Connection>>
|
||||
): Record<string, Connection> {
|
||||
const processedConnections: Record<string, Connection> = {};
|
||||
|
||||
for (const [serverName, config] of Object.entries(connections)) {
|
||||
if (typeof config !== 'object' || config === null) {
|
||||
logger.warn(`Invalid configuration for server "${serverName}". Skipping.`);
|
||||
debugLog(`WARN: Invalid configuration for server "${serverName}". Skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -514,22 +529,22 @@ export class MultiServerMCPClient {
|
||||
static fromConfigFile(configPath: string): MultiServerMCPClient {
|
||||
try {
|
||||
const client = new MultiServerMCPClient();
|
||||
const config = client.loadConfigFromFile(configPath);
|
||||
const config = MultiServerMCPClient.loadConfigFromFile(configPath);
|
||||
|
||||
// Merge with existing connections if any
|
||||
if (client.connections) {
|
||||
client.connections = {
|
||||
...client.connections,
|
||||
...client.processConnections(config.servers),
|
||||
...MultiServerMCPClient.processConnections(config.servers),
|
||||
};
|
||||
} else {
|
||||
client.connections = client.processConnections(config.servers);
|
||||
client.connections = MultiServerMCPClient.processConnections(config.servers);
|
||||
}
|
||||
|
||||
logger.info(`Loaded MCP configuration from ${configPath}`);
|
||||
debugLog(`INFO: Loaded MCP configuration from ${configPath}`);
|
||||
return client;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load MCP configuration from ${configPath}: ${error}`);
|
||||
debugLog(`ERROR: Failed to load MCP configuration from ${configPath}: ${error}`);
|
||||
throw new MCPClientError(`Failed to load MCP configuration: ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -542,12 +557,12 @@ export class MultiServerMCPClient {
|
||||
*/
|
||||
async initializeConnections(): Promise<Map<string, StructuredToolInterface[]>> {
|
||||
if (!this.connections || Object.keys(this.connections).length === 0) {
|
||||
logger.warn('No connections to initialize');
|
||||
debugLog(`WARN: No connections to initialize`);
|
||||
return new Map();
|
||||
}
|
||||
|
||||
for (const [serverName, connection] of Object.entries(this.connections)) {
|
||||
logger.info(`Initializing connection to server "${serverName}"...`);
|
||||
debugLog(`INFO: Initializing connection to server "${serverName}"...`);
|
||||
|
||||
if (connection.transport === 'stdio') {
|
||||
await this.initializeStdioConnection(serverName, connection);
|
||||
@@ -574,8 +589,8 @@ export class MultiServerMCPClient {
|
||||
): Promise<void> {
|
||||
const { command, args, env, restart } = connection;
|
||||
|
||||
logger.debug(
|
||||
`Creating stdio transport for server "${serverName}" with command: ${command} ${args.join(' ')}`
|
||||
debugLog(
|
||||
`DEBUG: Creating stdio transport for server "${serverName}" with command: ${command} ${args.join(' ')}`
|
||||
);
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
@@ -608,7 +623,7 @@ export class MultiServerMCPClient {
|
||||
this.clients.set(serverName, client);
|
||||
|
||||
const cleanup = async () => {
|
||||
logger.debug(`Closing stdio transport for server "${serverName}"`);
|
||||
debugLog(`DEBUG: Closing stdio transport for server "${serverName}"`);
|
||||
await transport.close();
|
||||
};
|
||||
|
||||
@@ -635,7 +650,7 @@ export class MultiServerMCPClient {
|
||||
|
||||
// Only attempt restart if we haven't cleaned up
|
||||
if (this.clients.has(serverName)) {
|
||||
logger.info(`Process for server "${serverName}" exited, attempting to restart...`);
|
||||
debugLog(`INFO: Process for server "${serverName}" exited, attempting to restart...`);
|
||||
await this.attemptReconnect(serverName, connection, restart.maxAttempts, restart.delayMs);
|
||||
}
|
||||
};
|
||||
@@ -650,7 +665,7 @@ export class MultiServerMCPClient {
|
||||
): Promise<void> {
|
||||
const { url, headers, useNodeEventSource, reconnect } = connection;
|
||||
|
||||
logger.debug(`Creating SSE transport for server "${serverName}" with URL: ${url}`);
|
||||
debugLog(`DEBUG: Creating SSE transport for server "${serverName}" with URL: ${url}`);
|
||||
|
||||
try {
|
||||
const transport = await this.createSSETransport(serverName, url, headers, useNodeEventSource);
|
||||
@@ -678,7 +693,7 @@ export class MultiServerMCPClient {
|
||||
this.clients.set(serverName, client);
|
||||
|
||||
const cleanup = async () => {
|
||||
logger.debug(`Closing SSE transport for server "${serverName}"`);
|
||||
debugLog(`DEBUG: Closing SSE transport for server "${serverName}"`);
|
||||
await transport.close();
|
||||
};
|
||||
|
||||
@@ -708,7 +723,7 @@ export class MultiServerMCPClient {
|
||||
return new SSEClientTransport(new URL(url));
|
||||
}
|
||||
|
||||
logger.debug(`Using custom headers for SSE transport to server "${serverName}"`);
|
||||
debugLog(`DEBUG: Using custom headers for SSE transport to server "${serverName}"`);
|
||||
|
||||
// If useNodeEventSource is true, try Node.js implementations
|
||||
if (useNodeEventSource) {
|
||||
@@ -716,16 +731,15 @@ export class MultiServerMCPClient {
|
||||
}
|
||||
|
||||
// For browser environments, use the basic requestInit approach
|
||||
logger.debug(
|
||||
`Using browser EventSource for server "${serverName}". Headers may not be applied correctly.`
|
||||
debugLog(
|
||||
`DEBUG: Using browser EventSource for server "${serverName}". Headers may not be applied correctly.`
|
||||
);
|
||||
logger.debug(
|
||||
`For better headers support in browsers, consider using a custom SSE implementation.`
|
||||
debugLog(
|
||||
`DEBUG: For better headers support in browsers, consider using a custom SSE implementation.`
|
||||
);
|
||||
|
||||
return new SSEClientTransport(new URL(url), {
|
||||
requestInit: { headers },
|
||||
eventSourceInit: { headers }, // Added for test compatibility
|
||||
});
|
||||
}
|
||||
|
||||
@@ -742,10 +756,11 @@ export class MultiServerMCPClient {
|
||||
const ExtendedEventSourceModule = await import('extended-eventsource');
|
||||
const ExtendedEventSource = ExtendedEventSourceModule.EventSource;
|
||||
|
||||
logger.debug(`Using Extended EventSource for server "${serverName}"`);
|
||||
logger.debug(`Setting headers for Extended EventSource: ${JSON.stringify(headers)}`);
|
||||
debugLog(`DEBUG: Using Extended EventSource for server "${serverName}"`);
|
||||
debugLog(`DEBUG: Setting headers for Extended EventSource: ${JSON.stringify(headers)}`);
|
||||
|
||||
// Override the global EventSource with the extended implementation
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).EventSource = ExtendedEventSource;
|
||||
|
||||
// For Extended EventSource, create the SSE transport
|
||||
@@ -756,36 +771,38 @@ export class MultiServerMCPClient {
|
||||
});
|
||||
} catch (extendedError) {
|
||||
// Fall back to standard eventsource if extended-eventsource is not available
|
||||
logger.debug(
|
||||
`Extended EventSource not available, falling back to standard EventSource: ${extendedError}`
|
||||
debugLog(
|
||||
`DEBUG: Extended EventSource not available, falling back to standard EventSource: ${extendedError}`
|
||||
);
|
||||
|
||||
try {
|
||||
// Dynamically import the eventsource package
|
||||
const EventSourceModule = await import('eventsource');
|
||||
const EventSource = EventSourceModule.default;
|
||||
const EventSource =
|
||||
'default' in EventSourceModule
|
||||
? EventSourceModule.default
|
||||
: EventSourceModule.EventSource;
|
||||
|
||||
logger.debug(`Using Node.js EventSource for server "${serverName}"`);
|
||||
logger.debug(`Setting headers for EventSource: ${JSON.stringify(headers)}`);
|
||||
debugLog(`DEBUG: Using Node.js EventSource for server "${serverName}"`);
|
||||
debugLog(`DEBUG: Setting headers for EventSource: ${JSON.stringify(headers)}`);
|
||||
|
||||
// Override the global EventSource with the Node.js implementation
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).EventSource = EventSource;
|
||||
|
||||
// Create transport with headers correctly configured for Node.js EventSource
|
||||
return new SSEClientTransport(new URL(url), {
|
||||
// Pass the headers to both eventSourceInit and requestInit for compatibility
|
||||
eventSourceInit: { headers },
|
||||
requestInit: { headers },
|
||||
});
|
||||
} catch (nodeError) {
|
||||
logger.warn(
|
||||
`Failed to load EventSource packages for server "${serverName}". Headers may not be applied to SSE connection: ${nodeError}`
|
||||
debugLog(
|
||||
`WARN: Failed to load EventSource packages for server "${serverName}". Headers may not be applied to SSE connection: ${nodeError}`
|
||||
);
|
||||
|
||||
// Last resort fallback
|
||||
return new SSEClientTransport(new URL(url), {
|
||||
requestInit: { headers },
|
||||
eventSourceInit: { headers },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -808,7 +825,9 @@ export class MultiServerMCPClient {
|
||||
|
||||
// Only attempt reconnect if we haven't cleaned up
|
||||
if (this.clients.has(serverName)) {
|
||||
logger.info(`SSE connection for server "${serverName}" closed, attempting to reconnect...`);
|
||||
debugLog(
|
||||
`INFO: SSE connection for server "${serverName}" closed, attempting to reconnect...`
|
||||
);
|
||||
await this.attemptReconnect(
|
||||
serverName,
|
||||
connection,
|
||||
@@ -824,10 +843,10 @@ export class MultiServerMCPClient {
|
||||
*/
|
||||
private async loadToolsForServer(serverName: string, client: Client): Promise<void> {
|
||||
try {
|
||||
logger.debug(`Loading tools for server "${serverName}"...`);
|
||||
const tools = await loadMcpTools(client);
|
||||
debugLog(`DEBUG: Loading tools for server "${serverName}"...`);
|
||||
const tools = await loadMcpTools(serverName, client);
|
||||
this.serverNameToTools.set(serverName, tools);
|
||||
logger.info(`Successfully loaded ${tools.length} tools from server "${serverName}"`);
|
||||
debugLog(`INFO: Successfully loaded ${tools.length} tools from server "${serverName}"`);
|
||||
} catch (error) {
|
||||
throw new MCPClientError(`Failed to load tools from server "${serverName}": ${error}`);
|
||||
}
|
||||
@@ -856,8 +875,8 @@ export class MultiServerMCPClient {
|
||||
|
||||
while (!connected && (maxAttempts === undefined || attempts < maxAttempts)) {
|
||||
attempts++;
|
||||
logger.info(
|
||||
`Reconnection attempt ${attempts}${maxAttempts ? `/${maxAttempts}` : ''} for server "${serverName}"`
|
||||
debugLog(
|
||||
`INFO: Reconnection attempt ${attempts}${maxAttempts ? `/${maxAttempts}` : ''} for server "${serverName}"`
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -876,17 +895,17 @@ export class MultiServerMCPClient {
|
||||
// Check if connected
|
||||
if (this.clients.has(serverName)) {
|
||||
connected = true;
|
||||
logger.info(`Successfully reconnected to server "${serverName}"`);
|
||||
debugLog(`INFO: Successfully reconnected to server "${serverName}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to reconnect to server "${serverName}" (attempt ${attempts}): ${error}`
|
||||
debugLog(
|
||||
`ERROR: Failed to reconnect to server "${serverName}" (attempt ${attempts}): ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
logger.error(`Failed to reconnect to server "${serverName}" after ${attempts} attempts`);
|
||||
debugLog(`ERROR: Failed to reconnect to server "${serverName}" after ${attempts} attempts`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -957,13 +976,13 @@ export class MultiServerMCPClient {
|
||||
* Close all connections.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
logger.info('Closing all MCP connections...');
|
||||
debugLog(`INFO: Closing all MCP connections...`);
|
||||
|
||||
for (const cleanup of this.cleanupFunctions) {
|
||||
try {
|
||||
await cleanup();
|
||||
} catch (error) {
|
||||
logger.error(`Error during cleanup: ${error}`);
|
||||
debugLog(`ERROR: Error during cleanup: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -972,7 +991,7 @@ export class MultiServerMCPClient {
|
||||
this.serverNameToTools.clear();
|
||||
this.transportInstances.clear();
|
||||
|
||||
logger.info('All MCP connections closed');
|
||||
debugLog(`INFO: All MCP connections closed`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1057,22 +1076,22 @@ export class MultiServerMCPClient {
|
||||
*/
|
||||
addConfigFromFile(configPath: string): MultiServerMCPClient {
|
||||
try {
|
||||
const config = this.loadConfigFromFile(configPath);
|
||||
const config = MultiServerMCPClient.loadConfigFromFile(configPath);
|
||||
|
||||
// Merge with existing connections if any
|
||||
if (this.connections) {
|
||||
this.connections = {
|
||||
...this.connections,
|
||||
...this.processConnections(config.servers),
|
||||
...MultiServerMCPClient.processConnections(config.servers),
|
||||
};
|
||||
} else {
|
||||
this.connections = this.processConnections(config.servers);
|
||||
this.connections = MultiServerMCPClient.processConnections(config.servers);
|
||||
}
|
||||
|
||||
logger.info(`Added MCP configuration from ${configPath}`);
|
||||
debugLog(`INFO: Added MCP configuration from ${configPath}`);
|
||||
return this;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to add MCP configuration from ${configPath}: ${error}`);
|
||||
debugLog(`ERROR: Failed to add MCP configuration from ${configPath}: ${error}`);
|
||||
throw new MCPClientError(`Failed to add MCP configuration: ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -1083,8 +1102,8 @@ export class MultiServerMCPClient {
|
||||
* @param connections - Server connections to add
|
||||
* @returns This client instance for method chaining
|
||||
*/
|
||||
addConnections(connections: Record<string, any>): MultiServerMCPClient {
|
||||
const processedConnections = this.processConnections(connections);
|
||||
addConnections(connections: Record<string, Connection>): MultiServerMCPClient {
|
||||
const processedConnections = MultiServerMCPClient.processConnections(connections);
|
||||
|
||||
// Merge with existing connections if any
|
||||
if (this.connections) {
|
||||
@@ -1096,7 +1115,7 @@ export class MultiServerMCPClient {
|
||||
this.connections = processedConnections;
|
||||
}
|
||||
|
||||
logger.info(`Added ${Object.keys(processedConnections).length} connections to client`);
|
||||
debugLog(`INFO: Added ${Object.keys(processedConnections).length} connections to client`);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -1,2 +1,6 @@
|
||||
export { MultiServerMCPClient } from './client.js';
|
||||
export { logger, enableLogging, disableLogging } from './logger.js';
|
||||
export {
|
||||
MultiServerMCPClient,
|
||||
type Connection,
|
||||
type StdioConnection,
|
||||
type SSEConnection,
|
||||
} from './client.js';
|
||||
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
import * as winston from 'winston';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Logging levels:
|
||||
* error: 0 - Severe errors that cause the application to crash or malfunction
|
||||
* warn: 1 - Warnings that don't stop the application but should be addressed
|
||||
* info: 2 - General information about application operation
|
||||
* http: 3 - HTTP request/response information
|
||||
* debug: 4 - Detailed debugging information
|
||||
*/
|
||||
const levels = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
debug: 4,
|
||||
};
|
||||
|
||||
/**
|
||||
* By default, logging is set to the silent level unless explicitly enabled
|
||||
* This makes logging opt-in rather than enabled by default
|
||||
*/
|
||||
const defaultLevel = 'silent';
|
||||
|
||||
/**
|
||||
* Define colors for each log level to improve readability in the console.
|
||||
*/
|
||||
const colors = {
|
||||
error: 'red',
|
||||
warn: 'yellow',
|
||||
info: 'green',
|
||||
http: 'magenta',
|
||||
debug: 'white',
|
||||
};
|
||||
|
||||
// Add colors to Winston
|
||||
winston.addColors(colors);
|
||||
|
||||
/**
|
||||
* Define the format for log messages.
|
||||
* We include a timestamp, colorize the output, and format the message.
|
||||
*/
|
||||
const format = winston.format.combine(
|
||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf(info => `${info.timestamp} ${info.level}: ${info.message}`)
|
||||
);
|
||||
|
||||
/**
|
||||
* Define the transports for log messages.
|
||||
* Always log to console, and only log to files if we have permission.
|
||||
*/
|
||||
// Base transports array (always include console)
|
||||
const transports: winston.transport[] = [
|
||||
// Console transport
|
||||
new winston.transports.Console(),
|
||||
];
|
||||
|
||||
// Attempt to add file transports only if we have write permissions
|
||||
try {
|
||||
const logsDir = path.join(process.cwd(), 'logs');
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Test write permissions with a small file
|
||||
const testFile = path.join(logsDir, '.permissions-test');
|
||||
fs.writeFileSync(testFile, 'test');
|
||||
fs.unlinkSync(testFile);
|
||||
|
||||
// If we reach here, we have write permissions - add file transports
|
||||
transports.push(
|
||||
// File transport for errors
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'error.log'),
|
||||
level: 'error',
|
||||
}),
|
||||
|
||||
// File transport for all logs
|
||||
new winston.transports.File({
|
||||
filename: path.join(logsDir, 'all.log'),
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
// If any error occurs during the file operations, log to console only
|
||||
console.warn(
|
||||
`Unable to set up file logging: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
console.warn('Falling back to console logging only');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the logger instance with our configuration.
|
||||
* By default, logging is disabled (silent) but can be enabled by setting the level.
|
||||
*/
|
||||
export const logger = winston.createLogger({
|
||||
level: defaultLevel, // Start with silent logging by default
|
||||
levels,
|
||||
format,
|
||||
transports,
|
||||
});
|
||||
|
||||
/**
|
||||
* Enable logging at the specified level.
|
||||
*
|
||||
* @param level - The log level to enable ('error', 'warn', 'info', 'http', 'debug')
|
||||
*/
|
||||
export function enableLogging(level: keyof typeof levels | 'silent' = 'info'): void {
|
||||
logger.level = level;
|
||||
logger.debug(`Logging enabled at level: ${level}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable all logging.
|
||||
*/
|
||||
export function disableLogging(): void {
|
||||
logger.level = 'silent';
|
||||
}
|
||||
|
||||
export default logger;
|
||||
+119
-78
@@ -1,28 +1,54 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import type {
|
||||
CallToolResult,
|
||||
TextContent,
|
||||
ImageContent,
|
||||
EmbeddedResource,
|
||||
ReadResourceResult,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import {
|
||||
DynamicStructuredTool,
|
||||
ResponseFormat,
|
||||
StructuredToolInterface,
|
||||
type DynamicStructuredToolInput,
|
||||
type StructuredToolInterface,
|
||||
} from '@langchain/core/tools';
|
||||
import {
|
||||
MessageContent,
|
||||
MessageContentComplex,
|
||||
MessageContentImageUrl,
|
||||
MessageContentText,
|
||||
} from '@langchain/core/messages';
|
||||
import { JSONSchema, JSONSchemaToZod } from '@dmitryrechkin/json-schema-to-zod';
|
||||
|
||||
import logger from './logger.js';
|
||||
import debug from 'debug';
|
||||
import { z } from 'zod';
|
||||
|
||||
interface TextContent {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
const {
|
||||
default: { name: packageName },
|
||||
} = await import('../package.json');
|
||||
const moduleName = 'tools';
|
||||
|
||||
interface NonTextContent {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
const debugLog = debug(`${packageName}:${moduleName}`);
|
||||
|
||||
interface CallToolResult {
|
||||
isError?: boolean;
|
||||
content?: (TextContent | NonTextContent)[] | string | object;
|
||||
type?: string;
|
||||
[key: string]: unknown;
|
||||
export type CallToolResultContentType = CallToolResult['content'][number]['type'];
|
||||
export type CallToolResultContent = TextContent | ImageContent | EmbeddedResource;
|
||||
|
||||
async function _embeddedResourceToArtifact(
|
||||
resource: EmbeddedResource,
|
||||
client: Client
|
||||
): Promise<EmbeddedResource[]> {
|
||||
if (!resource.blob && !resource.text && resource.uri) {
|
||||
const response: ReadResourceResult = await client.readResource({
|
||||
uri: resource.resource.uri,
|
||||
});
|
||||
|
||||
return response.contents.map(content => ({
|
||||
type: 'resource',
|
||||
resource: {
|
||||
...content,
|
||||
},
|
||||
}));
|
||||
}
|
||||
return [resource];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,38 +68,63 @@ export class ToolException extends Error {
|
||||
* @param result - The result from the MCP tool call
|
||||
* @returns A tuple of [textContent, nonTextContent]
|
||||
*/
|
||||
function _convertCallToolResult(
|
||||
result: CallToolResult
|
||||
): [string | string[], NonTextContent[] | null] {
|
||||
if (!result || !Array.isArray(result.content)) {
|
||||
return ['', null];
|
||||
}
|
||||
|
||||
const textContents: TextContent[] = [];
|
||||
const nonTextContents: NonTextContent[] = [];
|
||||
|
||||
// Separate text and non-text content
|
||||
for (const content of result.content) {
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
textContents.push(content as TextContent);
|
||||
} else {
|
||||
nonTextContents.push(content as NonTextContent);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the text content output
|
||||
const textOutput = textContents.map(content => content.text);
|
||||
const finalTextOutput = textOutput.length === 1 ? textOutput[0] : textOutput;
|
||||
|
||||
// Check for errors
|
||||
if (result.isError) {
|
||||
logger.error('MCP tool returned an error result');
|
||||
async function _convertCallToolResult(
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
result: CallToolResult,
|
||||
client: Client
|
||||
): Promise<[MessageContent, EmbeddedResource[]]> {
|
||||
if (!result) {
|
||||
throw new ToolException(
|
||||
typeof finalTextOutput === 'string' ? finalTextOutput : textOutput.join('\n')
|
||||
`MCP tool '${toolName}' on server '${serverName}' returned an invalid result - tool call response was undefined`
|
||||
);
|
||||
}
|
||||
|
||||
return [finalTextOutput, nonTextContents.length > 0 ? nonTextContents : null];
|
||||
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 mcpTextAndImageContent: MessageContentComplex[] = result.content
|
||||
.filter(content => content.type === 'text' || content.type === 'image')
|
||||
.map(content => {
|
||||
switch (content.type) {
|
||||
case 'text':
|
||||
return {
|
||||
type: 'text',
|
||||
text: content.text,
|
||||
} as MessageContentText;
|
||||
case 'image':
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${content.mimeType};base64,${content.data}`,
|
||||
},
|
||||
} as MessageContentImageUrl;
|
||||
}
|
||||
});
|
||||
|
||||
// Create the text content output
|
||||
const artifacts = (
|
||||
await Promise.all(
|
||||
result.content
|
||||
.filter(content => content.type === 'resource')
|
||||
.map(content => _embeddedResourceToArtifact(content, client))
|
||||
)
|
||||
).flat();
|
||||
|
||||
if (mcpTextAndImageContent.length === 1 && mcpTextAndImageContent[0].type === 'text') {
|
||||
return [mcpTextAndImageContent[0].text, artifacts];
|
||||
}
|
||||
|
||||
return [mcpTextAndImageContent, artifacts];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,63 +135,48 @@ function _convertCallToolResult(
|
||||
* @internal
|
||||
*
|
||||
* @param client - The MCP client
|
||||
* @param name - The name of the tool (forwarded to the client)
|
||||
* @param responseFormat - The response format
|
||||
* @param toolName - The name of the tool (forwarded to the client)
|
||||
* @param args - The arguments to pass to the tool
|
||||
* @returns A tuple of [textContent, nonTextContent]
|
||||
*/
|
||||
async function _callTool(
|
||||
serverName: string,
|
||||
toolName: string,
|
||||
client: Client,
|
||||
name: string,
|
||||
responseFormat: ResponseFormat,
|
||||
args: Record<string, unknown>
|
||||
): Promise<string | [string | string[], NonTextContent[] | null]> {
|
||||
): Promise<[MessageContent, EmbeddedResource[]]> {
|
||||
let result: CallToolResult;
|
||||
try {
|
||||
logger.info(`Calling tool ${name}(${JSON.stringify(args)})`);
|
||||
const result = await client.callTool({
|
||||
name,
|
||||
debugLog(`INFO: Calling tool ${toolName}(${JSON.stringify(args)})`);
|
||||
result = (await client.callTool({
|
||||
name: toolName,
|
||||
arguments: args,
|
||||
});
|
||||
|
||||
const [textContent, nonTextContent] = _convertCallToolResult({
|
||||
...result,
|
||||
isError: result.isError === true,
|
||||
content: result.content || [],
|
||||
});
|
||||
|
||||
logger.info(`Tool ${name} returned: ${JSON.stringify({ textContent, nonTextContent })}`);
|
||||
|
||||
// Return based on the response format
|
||||
if (responseFormat === 'content_and_artifact') {
|
||||
return [textContent, nonTextContent];
|
||||
}
|
||||
|
||||
// Default to returning just the text content
|
||||
return typeof textContent === 'string' ? textContent : textContent.join('\n');
|
||||
})) as CallToolResult;
|
||||
} catch (error) {
|
||||
logger.error(`Error calling tool ${name}: ${String(error)}`);
|
||||
debugLog(`Error calling tool ${toolName}: ${String(error)}`);
|
||||
if (error instanceof ToolException) {
|
||||
throw error;
|
||||
}
|
||||
throw new ToolException(`Error calling tool ${name}: ${String(error)}`);
|
||||
throw new ToolException(`Error calling tool ${toolName}: ${String(error)}`);
|
||||
}
|
||||
|
||||
return _convertCallToolResult(serverName, toolName, result, client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all tools from an MCP client.
|
||||
*
|
||||
* @param client - The MCP client
|
||||
* @param responseFormat - Response format ('text' or 'content_and_artifact')
|
||||
* @returns A list of LangChain tools
|
||||
*/
|
||||
export async function loadMcpTools(
|
||||
serverName: string,
|
||||
client: Client,
|
||||
responseFormat: ResponseFormat = 'content',
|
||||
throwOnLoadError: boolean = true
|
||||
): Promise<StructuredToolInterface[]> {
|
||||
// Get tools in a single operation
|
||||
const toolsResponse = await client.listTools();
|
||||
logger.info(`Found ${toolsResponse.tools?.length || 0} MCP tools`);
|
||||
debugLog(`INFO: Found ${toolsResponse.tools?.length || 0} MCP tools`);
|
||||
|
||||
// Filter out tools without names and convert in a single map operation
|
||||
return (toolsResponse.tools || [])
|
||||
@@ -153,13 +189,18 @@ export async function loadMcpTools(
|
||||
schema: JSONSchemaToZod.convert(
|
||||
(tool.inputSchema ?? { type: 'object', properties: {} }) as JSONSchema
|
||||
),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
func: _callTool.bind(null, client, tool.name, responseFormat) as any,
|
||||
responseFormat: 'content_and_artifact',
|
||||
func: _callTool.bind(
|
||||
null,
|
||||
serverName,
|
||||
tool.name,
|
||||
client
|
||||
) as DynamicStructuredToolInput<z.AnyZodObject>['func'],
|
||||
});
|
||||
logger.debug(`Successfully loaded tool: ${dst.name}`);
|
||||
debugLog(`INFO: Successfully loaded tool: ${dst.name}`);
|
||||
return dst;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load tool "${tool.name}":`, error);
|
||||
debugLog(`ERROR: Failed to load tool "${tool.name}":`, error);
|
||||
if (throwOnLoadError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
Vendored
-26
@@ -1,26 +0,0 @@
|
||||
declare module 'eventsource' {
|
||||
export default class EventSource {
|
||||
constructor(
|
||||
url: string,
|
||||
options?: {
|
||||
headers?: Record<string, string>;
|
||||
https?: any;
|
||||
rejectUnauthorized?: boolean;
|
||||
withCredentials?: boolean;
|
||||
}
|
||||
);
|
||||
|
||||
onopen: (event: any) => void;
|
||||
onmessage: (event: any) => void;
|
||||
onerror: (event: any) => void;
|
||||
|
||||
close(): void;
|
||||
|
||||
static readonly CONNECTING: number;
|
||||
static readonly OPEN: number;
|
||||
static readonly CLOSED: number;
|
||||
|
||||
readonly readyState: number;
|
||||
readonly url: string;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"downlevelIteration": true,
|
||||
"noEmit": true // we just want type checking - no need to output for inclusion in the published module
|
||||
"noEmit": true, // we just want type checking - no need to output for inclusion in the published module
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["examples/**/*", "src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
|
||||
+2
-1
@@ -10,7 +10,8 @@
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"downlevelIteration": true
|
||||
"downlevelIteration": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts", "examples/**/*"]
|
||||
|
||||
Reference in New Issue
Block a user