Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions multimodal/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion multimodal/tarko/mcp-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
"dev": "rslib build --watch",
"build": "rslib build",
"prepublishOnly": "pnpm run build",
"test": "vitest run",
"test:watch": "vitest",
"agent:snapshot:genreate": "npx tsx snapshot/runner.ts generate all",
"agent:snapshot:test": "npx vitest snapshot/index.test.ts"
},
Expand All @@ -38,6 +40,7 @@
"devDependencies": {
"@rslib/core": "0.10.0",
"@types/node": "22.15.30",
"typescript": "^5.5.3"
"typescript": "^5.5.3",
"vitest": "3.2.4"
}
}
88 changes: 55 additions & 33 deletions multimodal/tarko/mcp-agent/src/mcp-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,47 +55,69 @@ export class MCPAgent<T extends MCPAgentOptions = MCPAgentOptions> extends Agent

// Initialize MCP clients and register tools
for (const [serverName, config] of Object.entries(filteredMcpServerConfig)) {
try {
this.logger.info(`🔌 Connecting to MCP server: ${serverName}`);
this.logger.info(`🔌 Connecting to MCP server: ${serverName}`);

// Create MCP client using v2
const defaultTimeout = this.options.defaultConnectionTimeout ?? 60;
const mcpClient = new MCPClientV2(serverName, config, this.logger, defaultTimeout);
const defaultTimeout = this.options.defaultConnectionTimeout ?? 60;
let mcpClient: MCPClientV2 | undefined;

// Initialize the client and get tools
try {
mcpClient = new MCPClientV2(serverName, config, this.logger, defaultTimeout);
await mcpClient.initialize();

// Store the client for later use
this.mcpClients.set(serverName, mcpClient);

// Create and register tools directly
const mcpTools = mcpClient.getTools();
for (const mcpTool of mcpTools) {
const tool = new Tool({
id: mcpTool.name,
description: `[${serverName}] ${mcpTool.description}`,
parameters: (mcpTool.inputSchema || {
type: 'object',
properties: {},
}) as JSONSchema7,
function: async (args: Record<string, unknown>) => {
return await mcpClient.callTool(mcpTool.name, args);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const message = `Failed to initialize MCP server "${serverName}": ${errorMessage}`;

this.logger.error(`❌ ${message}`);

const eventStream = this.getEventStream();
eventStream.sendEvent(
eventStream.createEvent('system', {
level: 'error',
message,
details: {
source: 'mcp',
phase: 'initialization',
serverName,
},
});
this.registerTool(tool as unknown as Tool);
}),
);

if (mcpClient) {
try {
await mcpClient.close();
} catch (cleanupError) {
this.logger.error(
`Failed to clean up MCP client ${serverName}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
);
}
}

const toolCount = mcpTools.length;
continue;
}

this.logger.success(`✅ Connected to MCP server ${serverName} with ${toolCount} tools`);
} catch (error) {
this.logger.error(
`❌ Failed to connect to MCP server ${serverName}: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
throw new Error(
`❌ Failed to connect to MCP server ${serverName}: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
);
// Store the client for later use
this.mcpClients.set(serverName, mcpClient);

// Create and register tools directly
const mcpTools = mcpClient.getTools();
for (const mcpTool of mcpTools) {
const tool = new Tool({
id: mcpTool.name,
description: `[${serverName}] ${mcpTool.description}`,
parameters: (mcpTool.inputSchema || {
type: 'object',
properties: {},
}) as JSONSchema7,
function: async (args: Record<string, unknown>) => {
return await mcpClient.callTool(mcpTool.name, args);
},
});
this.registerTool(tool as unknown as Tool);
}

const toolCount = mcpTools.length;

this.logger.success(`✅ Connected to MCP server ${serverName} with ${toolCount} tools`);
}

super.initialize();
Expand Down
20 changes: 16 additions & 4 deletions multimodal/tarko/mcp-agent/src/mcp-client-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class MCPClientV2 implements IMCPClient {
private v2Client: V2Client;
private serverName: string;
private tools: Tool[] = [];
private hasStarted = false;
private isInitialized = false;

constructor(
Expand Down Expand Up @@ -45,6 +46,7 @@ export class MCPClientV2 implements IMCPClient {

try {
this.logger.info(`Initializing MCP client v2 for ${this.serverName}`);
this.hasStarted = true;
await this.v2Client.init();
this.tools = await this.v2Client.listTools(this.serverName as string);
this.isInitialized = true;
Expand Down Expand Up @@ -75,18 +77,28 @@ export class MCPClientV2 implements IMCPClient {
return result.content;
} catch (error) {
this.logger.error(`Error calling MCP tool ${toolName}:`, error);
throw new Error(`Failed to execute tool ${toolName}: ${error instanceof Error ? error.message : String(error)}`);
throw new Error(
`Failed to execute tool ${toolName}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

async close(): Promise<void> {
if (this.isInitialized) {
this.logger.info(`Closing MCP client v2 for ${this.serverName}`);
if (!this.hasStarted) {
return;
}

this.logger.info(`Closing MCP client v2 for ${this.serverName}`);

try {
await this.v2Client.cleanup();
} finally {
this.hasStarted = false;
this.isInitialized = false;
this.tools = [];
this.logger.success(`MCP client v2 closed successfully for ${this.serverName}`);
}

this.logger.success(`MCP client v2 closed successfully for ${this.serverName}`);
}

getTools(): Tool[] {
Expand Down
136 changes: 136 additions & 0 deletions multimodal/tarko/mcp-agent/tests/mcp-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Copyright (c) 2025 Bytedance, Inc. and its affiliates.
* SPDX-License-Identifier: Apache-2.0
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Tool as MCPTool } from '@modelcontextprotocol/sdk/types.js';

const { constructClient, initializeClient, closeClient, getClientTools } = vi.hoisted(() => ({
constructClient: vi.fn<(serverName: string) => void>(),
initializeClient: vi.fn<(serverName: string) => Promise<MCPTool[]>>(),
closeClient: vi.fn<(serverName: string) => Promise<void>>(),
getClientTools: vi.fn<(serverName: string) => MCPTool[]>(),
}));

vi.mock('../src/mcp-client-v2', () => ({
MCPClientV2: class {
constructor(private serverName: string) {
constructClient(serverName);
}

initialize() {
return initializeClient(this.serverName);
}

close() {
return closeClient(this.serverName);
}

getTools() {
return getClientTools(this.serverName);
}

callTool() {
return Promise.resolve(undefined);
}
},
}));

import { MCPAgent } from '../src/mcp-agent';

const healthyTool: MCPTool = {
name: 'healthy_tool',
description: 'A tool from the healthy server',
inputSchema: {
type: 'object',
properties: {},
},
};

describe('MCPAgent initialization', () => {
beforeEach(() => {
vi.clearAllMocks();

constructClient.mockImplementation(() => undefined);
initializeClient.mockImplementation(async (serverName) => {
if (serverName === 'broken') {
throw new Error('connection refused');
}

return [healthyTool];
});
closeClient.mockResolvedValue(undefined);
getClientTools.mockImplementation((serverName) =>
serverName === 'healthy' ? [healthyTool] : [],
);
});

it('surfaces a failed server and continues initializing the remaining servers', async () => {
const agent = new MCPAgent({
mcpServers: {
broken: { command: 'broken-server' },
healthy: { command: 'healthy-server' },
},
});

await expect(agent.initialize()).resolves.toBeUndefined();

expect(initializeClient).toHaveBeenNthCalledWith(1, 'broken');
expect(initializeClient).toHaveBeenNthCalledWith(2, 'healthy');
expect(closeClient).toHaveBeenCalledWith('broken');
expect(agent.getTools().map((tool) => tool.name)).toEqual(['healthy_tool']);

const errorEvents = agent
.getEventStream()
.getEvents()
.filter((event) => event.type === 'system' && event.level === 'error');

expect(errorEvents).toEqual([
expect.objectContaining({
type: 'system',
level: 'error',
message: 'Failed to initialize MCP server "broken": connection refused',
details: {
source: 'mcp',
phase: 'initialization',
serverName: 'broken',
},
}),
]);
});

it('surfaces client construction failures without blocking later servers', async () => {
constructClient.mockImplementation((serverName) => {
if (serverName === 'broken') {
throw new Error('invalid transport configuration');
}
});

const agent = new MCPAgent({
mcpServers: {
broken: { command: 'broken-server' },
healthy: { command: 'healthy-server' },
},
});

await expect(agent.initialize()).resolves.toBeUndefined();

expect(initializeClient).toHaveBeenCalledOnce();
expect(initializeClient).toHaveBeenCalledWith('healthy');
expect(closeClient).not.toHaveBeenCalledWith('broken');
expect(agent.getTools().map((tool) => tool.name)).toEqual(['healthy_tool']);
expect(agent.getEventStream().getEvents()).toEqual([
expect.objectContaining({
type: 'system',
level: 'error',
message: 'Failed to initialize MCP server "broken": invalid transport configuration',
details: {
source: 'mcp',
phase: 'initialization',
serverName: 'broken',
},
}),
]);
});
});
56 changes: 56 additions & 0 deletions multimodal/tarko/mcp-agent/tests/mcp-client-v2.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2025 Bytedance, Inc. and its affiliates.
* SPDX-License-Identifier: Apache-2.0
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Logger } from '@agent-infra/logger';

const { initClient, listTools, cleanupClient } = vi.hoisted(() => ({
initClient: vi.fn<() => Promise<void>>(),
listTools: vi.fn<() => Promise<never>>(),
cleanupClient: vi.fn<() => Promise<void>>(),
}));

vi.mock('@agent-infra/mcp-client', () => ({
MCPClient: class {
init() {
return initClient();
}

listTools() {
return listTools();
}

cleanup() {
return cleanupClient();
}
},
}));

import { MCPClientV2 } from '../src/mcp-client-v2';

const logger = {
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
} as unknown as Logger;

describe('MCPClientV2 cleanup', () => {
beforeEach(() => {
vi.clearAllMocks();
initClient.mockResolvedValue(undefined);
listTools.mockRejectedValue(new Error('connection closed'));
cleanupClient.mockResolvedValue(undefined);
});

it('cleans up a partially initialized client after initialization fails', async () => {
const client = new MCPClientV2('broken', { command: 'broken-server' }, logger);

await expect(client.initialize()).rejects.toThrow('connection closed');
await client.close();
await client.close();

expect(cleanupClient).toHaveBeenCalledOnce();
});
});
12 changes: 12 additions & 0 deletions multimodal/tarko/mcp-agent/vitest.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance, Inc. and its affiliates.
* SPDX-License-Identifier: Apache-2.0
*/
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
environment: 'node',
include: ['**/*.test.ts'],
},
});