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
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { TestingSupportModule } from './testing/testing.module';
import { IntegrationsModule } from './integrations/integrations.module';
import { SchedulesModule } from './schedules/schedules.module';
import { AnalyticsModule } from './analytics/analytics.module';
import { McpModule } from './mcp/mcp.module';

import { ApiKeysModule } from './api-keys/api-keys.module';
import { WebhooksModule } from './webhooks/webhooks.module';
Expand All @@ -37,6 +38,7 @@ const coreModules = [
ApiKeysModule,
WebhooksModule,
HumanInputsModule,
McpModule,
];

const testingModules =
Expand Down
231 changes: 231 additions & 0 deletions backend/src/mcp/__tests__/tool-registry.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { describe, it, expect, beforeEach, mock } from 'bun:test';
import { ToolRegistryService, TOOL_REGISTRY_REDIS } from '../tool-registry.service';
import type { SecretsEncryptionService } from '../../secrets/secrets.encryption';

// Mock Redis
class MockRedis {
private data: Map<string, Map<string, string>> = new Map();

async hset(key: string, field: string, value: string): Promise<number> {
if (!this.data.has(key)) {
this.data.set(key, new Map());
}
this.data.get(key)!.set(field, value);
return 1;
}

async hget(key: string, field: string): Promise<string | null> {
return this.data.get(key)?.get(field) ?? null;
}

async hgetall(key: string): Promise<Record<string, string>> {
const hash = this.data.get(key);
if (!hash) return {};
return Object.fromEntries(hash.entries());
}

async del(key: string): Promise<number> {
this.data.delete(key);
return 1;
}

async expire(_key: string, _seconds: number): Promise<number> {
return 1;
}

async quit(): Promise<void> {}
}

// Mock encryption service
class MockEncryptionService {
async encrypt(value: string): Promise<{ ciphertext: string; keyId: string }> {
return {
ciphertext: Buffer.from(value).toString('base64'),
keyId: 'test-key',
};
}

async decrypt(material: { ciphertext: string }): Promise<string> {
return Buffer.from(material.ciphertext, 'base64').toString('utf-8');
}
}

describe('ToolRegistryService', () => {
let service: ToolRegistryService;
let redis: MockRedis;
let encryption: MockEncryptionService;

beforeEach(() => {
redis = new MockRedis();
encryption = new MockEncryptionService();
service = new ToolRegistryService(
redis as any,
encryption as any as SecretsEncryptionService,
);
});

describe('registerComponentTool', () => {
it('registers a component tool with encrypted credentials', async () => {
await service.registerComponentTool({
runId: 'run-1',
nodeId: 'node-a',
toolName: 'check_ip_reputation',
componentId: 'security.abuseipdb',
description: 'Check IP reputation',
inputSchema: {
type: 'object',
properties: { ipAddress: { type: 'string' } },
required: ['ipAddress'],
},
credentials: { apiKey: 'secret-123' },
});

const tool = await service.getTool('run-1', 'node-a');
expect(tool).not.toBeNull();
expect(tool?.toolName).toBe('check_ip_reputation');
expect(tool?.status).toBe('ready');
expect(tool?.type).toBe('component');
expect(tool?.encryptedCredentials).toBeDefined();
});
});

describe('getToolsForRun', () => {
it('returns all tools for a run', async () => {
await service.registerComponentTool({
runId: 'run-1',
nodeId: 'node-a',
toolName: 'tool_a',
componentId: 'comp.a',
description: 'Tool A',
inputSchema: { type: 'object', properties: {}, required: [] },
credentials: {},
});

await service.registerComponentTool({
runId: 'run-1',
nodeId: 'node-b',
toolName: 'tool_b',
componentId: 'comp.b',
description: 'Tool B',
inputSchema: { type: 'object', properties: {}, required: [] },
credentials: {},
});

const tools = await service.getToolsForRun('run-1');
expect(tools.length).toBe(2);
expect(tools.map(t => t.toolName).sort()).toEqual(['tool_a', 'tool_b']);
});
});

describe('getToolByName', () => {
it('finds a tool by name', async () => {
await service.registerComponentTool({
runId: 'run-1',
nodeId: 'node-a',
toolName: 'my_tool',
componentId: 'comp.a',
description: 'My Tool',
inputSchema: { type: 'object', properties: {}, required: [] },
credentials: {},
});

const tool = await service.getToolByName('run-1', 'my_tool');
expect(tool).not.toBeNull();
expect(tool?.nodeId).toBe('node-a');
});

it('returns null for unknown tool name', async () => {
const tool = await service.getToolByName('run-1', 'unknown');
expect(tool).toBeNull();
});
});

describe('getToolCredentials', () => {
it('decrypts and returns credentials', async () => {
await service.registerComponentTool({
runId: 'run-1',
nodeId: 'node-a',
toolName: 'tool',
componentId: 'comp',
description: 'Tool',
inputSchema: { type: 'object', properties: {}, required: [] },
credentials: { apiKey: 'secret-value', token: 'another-secret' },
});

const creds = await service.getToolCredentials('run-1', 'node-a');
expect(creds).toEqual({ apiKey: 'secret-value', token: 'another-secret' });
});
});

describe('areAllToolsReady', () => {
it('returns true when all required tools are ready', async () => {
await service.registerComponentTool({
runId: 'run-1',
nodeId: 'node-a',
toolName: 'tool_a',
componentId: 'comp.a',
description: 'Tool A',
inputSchema: { type: 'object', properties: {}, required: [] },
credentials: {},
});

await service.registerComponentTool({
runId: 'run-1',
nodeId: 'node-b',
toolName: 'tool_b',
componentId: 'comp.b',
description: 'Tool B',
inputSchema: { type: 'object', properties: {}, required: [] },
credentials: {},
});

const ready = await service.areAllToolsReady('run-1', ['node-a', 'node-b']);
expect(ready).toBe(true);
});

it('returns false when a required tool is missing', async () => {
await service.registerComponentTool({
runId: 'run-1',
nodeId: 'node-a',
toolName: 'tool_a',
componentId: 'comp.a',
description: 'Tool A',
inputSchema: { type: 'object', properties: {}, required: [] },
credentials: {},
});

const ready = await service.areAllToolsReady('run-1', ['node-a', 'node-b']);
expect(ready).toBe(false);
});
});

describe('cleanupRun', () => {
it('removes all tools and returns container IDs', async () => {
await service.registerComponentTool({
runId: 'run-1',
nodeId: 'node-a',
toolName: 'tool_a',
componentId: 'comp.a',
description: 'Tool A',
inputSchema: { type: 'object', properties: {}, required: [] },
credentials: {},
});

await service.registerLocalMcp({
runId: 'run-1',
nodeId: 'node-mcp',
toolName: 'steampipe',
description: 'Steampipe MCP',
inputSchema: { type: 'object', properties: {}, required: [] },
endpoint: 'http://localhost:8080',
containerId: 'container-123',
});

const containerIds = await service.cleanupRun('run-1');
expect(containerIds).toEqual(['container-123']);

const tools = await service.getToolsForRun('run-1');
expect(tools.length).toBe(0);
});
});
});
2 changes: 2 additions & 0 deletions backend/src/mcp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './mcp.module';
export * from './tool-registry.service';
25 changes: 25 additions & 0 deletions backend/src/mcp/mcp.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Global, Module } from '@nestjs/common';
import Redis from 'ioredis';
import { ToolRegistryService, TOOL_REGISTRY_REDIS } from './tool-registry.service';
import { SecretsModule } from '../secrets/secrets.module';

@Global()
@Module({
imports: [SecretsModule],
providers: [
{
provide: TOOL_REGISTRY_REDIS,
useFactory: () => {
// Use the same Redis URL as terminal or a dedicated one
const url = process.env.TOOL_REGISTRY_REDIS_URL ?? process.env.TERMINAL_REDIS_URL;
if (!url) {
return null;
}
return new Redis(url);
},
},
ToolRegistryService,
],
exports: [ToolRegistryService],
})
export class McpModule {}
Loading